67 KiB
Eidos Theming — Architecture Reference
Audiencia: cualquier dev que abra el repo y necesite entender cómo se hace el theming en UIX. Cubre el modelo mental, los contratos, las herramientas y las trampas. Si después de leerlo todavía no sabes dónde poner un token nuevo, falló este doc — abre un issue.
TL;DR:
- 9 roles canónicos de color (
primary,secondary,tertiary,neutral,affirm,fulfill,risk,threat,loss). - 6 sizes canónicos +
full(xxs..xxl). - 3 niveles de tokens públicos: foundation (estable), per-component
recipe (overrideable), private (
--_*, no contrato externo). - Token Scope Contract (TSC) decide DÓNDE se emite cada token
(
:root/[data-{c}]/[data-{c}][data-color='X']/ etc.) y valida transitividad al generar. - 226 KB raw / 25 KB gzip de CSS foundation por defecto. Usa
npm run eidos:purgepara apps en producción → −46 a −55%. - Modelo de color: paleta de 33 escalas (diseñable) → roles de jerarquía (alias explícito) → intents (auto-derivados por convención del libro, identidad = step 9). Ver §25.
- Compatible con persistencia versionada, themes CSS-only, runtime overrides, dark/light, density (compact/comfortable/spacious), scaling (zoom 90–110, eje aparte), reduced motion, multi-axis breakpoints.
Tabla de contenidos
- Mental model 1bis. Theming vive en Eidos, no en Morfo (por diseño)
- Las capas del CSS de Eidos
- Las 7 capas de tokens
- Los 9 roles canónicos de color
- El canon de sizes
- Convenciones de naming
- Token Scope Contract (TSC)
- Cómo añadir un componente nuevo
- Cómo definir un theme
- Cómo overridear tokens en runtime
- Bundle strategy +
eidos:purge - Herramientas de validación
- Integración con Sema (
event:*scope) - Motion
- Comparación con librerías de referencia
- Anti-patterns que NO debes cometer
- FAQ — decisiones polémicas
- Cobertura universal de TSC
- Variants son canon del eidos, NO del theme
- Correcciones del engine de theming (2026-06-01)
- Modelo de color de dos niveles (RFC — RESUELTO en §25)
- Mejoras pendientes del theming
- Eje de
scaling(zoom global) - Correcciones P2 del engine (2026-06-02)
- Modelo de color — paleta + roles/intents derivados
- Theme builder en runtime —
eidos.applyColorScheme - Salida wide-gamut OKLCH (default-on)
- Accesibilidad forced-colors + ramp de bordes
- Profundidad (depth) — canal unificado + eventful
- Forma (shape) — continuidad + familias + anidado
- Estructura (espacio · densidad · escala)
- Focus ring — modelo de dos anillos parametrizado
- Glifos de stepper themeables (
spin-field) spin-field— visual compartido del stepper-field- Canon de escalas — auditoría de theming (2026-06-15)
- Gap canónico trigger→panel — offset token-driven (2026-06-22)
- Touch-target — 44px en táctil, gated por puntero (2026-06-28)
- Capa de estado (state-layer) — feedback neutro unificado (2026-06-28)
1. Mental model
Eidos es la capa visual de UIX. NO posee comportamiento ni estado. Lee del DOM lo que las capas anteriores escribieron y aplica estilos.
Morfo declara la genética (qué attrs / events / partes existen)
↓
Soma transcribe behavior (data-state, data-color, aria-*, focus, …)
↓
Sema emite señales (data-event-* durante el hold perceptual)
↓
Eidos aplica visual (tokens, themes, recipes, archetypes, motion)
Lo que Eidos posee:
- El namespace
--*de custom properties. - Las capas de CSS del entrypoint (§2: foundation generado, archetypes,
events, recipes) + los bloques de theme que inyecta
ActiveEidos. - El runtime
ActiveEidosque inyecta foundation + theme CSS. - Tooling: generación, validación, purge, lint.
Lo que Eidos NO posee:
- Estado lógico de componentes (eso es soma).
- Definición de qué events existen (eso es morfo).
- Disparar señales perceptivas (eso es sema).
La regla del 2-de-3: una extensión al sistema (atributo, token,
convención) sólo se justifica si al menos dos de las tres capas
(soma, sema, eidos) la consumen. Las extensiones que entraron por voto
de eidos: archetype, events[].semantic.{family,intent},
events[].prewrite[], data-starting-style / data-ending-style.
1.bis Theming vive en Eidos, no en Morfo (por diseño)
Esta es la pregunta arquitectónica más frecuente — y la respuesta más importante para no romper el sistema.
La intuición razonable de un dev nuevo es: "si morfo es la fuente de verdad cross-layer, los tokens visuales deberían vivir en morfo también". NO. El diseño explícito de UIX dice lo contrario. Esta sección existe para cerrar el caso con citas, antes de que la confusión arrastre a un PR que viole la arquitectura.
Las dos citas canónicas del repo
src/uix/active_architecture.md §9 (Lo que NO es esta arquitectura):
No es un design system clásico. Tokens, themes y recipes pertenecen a Eidos, no al núcleo.
src/uix/README.md §2 (Eidos):
Capa visual: tokens, themes, recipes CSS por componente, archetype rules, event reactions y wrappers Svelte sobre los providers headless de soma…
Eidos lee del DOM lo que las otras capas escriben — nunca importa internals de soma ni de sema.
Estas dos frases, por sí solas, cierran cualquier debate sobre dónde vive el theming. Si una propuesta futura las contradice, la propuesta debe rechazarse o el doc canónico debe actualizarse antes — no después.
La regla 2-de-3 lo deriva mecánicamente
active_architecture.md §7 #12 y README.md §5 dicen lo mismo:
Una extensión a morfo solo se justifica si al menos dos de las tres capas (soma, sema, eidos) la consumen.
Aplicado al theming:
| ¿Quién consume los tokens visuales? | |
|---|---|
| Soma (behavior runtime) | ❌ no |
| Sema (perceptual signals) | ❌ no |
| Eidos (visual layer) | ✅ sí |
| Cuenta | 1-de-3 |
1-de-3 ≠ 2-de-3 → tokens NO van en morfo, por regla. La integración visual cae automáticamente en eidos por la disciplina del 2-de-3, sin que nadie tenga que decidirlo per-caso.
¿Cuál es entonces la relación entre morfo y theming?
Morfo es source-of-truth del contrato cross-layer:
- Parts (qué partes existen)
- Events (qué eventos puede disparar)
- Attrs y sus valores enumerados (qué attrs aparecen en DOM con qué values)
- Archetypes (clasificación transversal)
- States declarativos
El theming se integra con morfo en UN SENTIDO PRECISO: las recipes de eidos targetean DOM attrs que morfo declara. Sin morfo, los attrs no existirían en el DOM y los selectores eidos estarían muertos.
MORFO declara data-color.values = ['primary', 'affirm', 'threat', ...]
↓
SOMA emite <button data-color="affirm"> (al DOM)
↓
EIDOS recipe targetea scope: 'color:affirm' → [data-toggle][data-color='affirm']
↓
BROWSER cascade resuelve la regla CSS
El canal entre morfo y eidos es el DOM, no objetos TypeScript. Esto es crítico y está blindado por la regla #6 de las reglas duras:
active_architecture.md §7 #6:
Eidos consume DOM y data-*, no internals de Soma ni Sema. Si lo necesita, debe estar declarado en morfo o emitido en una señal de sema.
README.md §5 lo repite literalmente.
Lo que NO debe hacer eidos jamás
// ❌ VIOLACIÓN ARQUITECTÓNICA — Eidos importando morfo en runtime
import { toggleMorfo } from '$uix/morfo/components/toggle';
const validValues = toggleMorfo.parts
.find((p) => p.kebab === 'provider')!
.data.find((d) => d.attr === 'data-color')!.values;
// usar validValues para validar TSC scope:'color:X'
Aunque la intención sea buena (validar que scope: 'color:affirm' matchee
un value morfo-declarado), este import viola la regla #6 y rompe el
boundary morfo↔eidos. Si quieres esa validación, la defensa correcta es
eidos-lint al nivel del DOM/CSS, no acoplamiento TS.
La defensa correcta: eidos-lint al nivel del DOM/CSS
La validación de que las recipes eidos targetean valores que morfo declara SE HACE, pero al nivel DOM/CSS:
node scripts/eidos-lint.ts toggle
Clasifica cada selector [data-*] como:
- morfo-backed — declarado en morfo, soma lo emite con value válido
- eidos-only — attr añadido por wrapper (data-variant, data-size)
- invalid — referencia un attr morfo-backed con value fuera del enum → bug
Esto cierra el loop arquitectónicamente sin requerir imports cross-layer.
Recapitulación de la división canónica
| Concepto | Source-of-truth | Justificación |
|---|---|---|
| Parts (qué partes existen) | Morfo | Cross-layer: soma emite, eidos selecciona, sema referencia |
| Events + semantic | Morfo | Cross-layer: soma trigger, sema dispatch, eidos reaction |
| Archetypes | Morfo | Cross-layer: soma emite, sema cascade, eidos selectores |
Attr values (data-color.values) |
Morfo | Cross-layer: soma valida, eidos targetea, sema referencia |
| States declarativos | Morfo | Cross-layer: soma emite data-state, eidos selecciona |
| 9 roles canónicos sistémicos | Eidos (lib/themes/base.ts) |
Solo eidos los materializa |
| 6 sizes canónicos | Eidos (lib/config-types.ts) |
Solo eidos los coordina |
| Variants visuales (solid/outline/ghost) | Eidos wrapper | Solo eidos las renderiza |
Tokens (--toggle-solid-on-bg) |
Eidos (lib/recipes/base.ts) |
Solo eidos los consume |
| Themes (light/dark/custom) | Eidos (lib/themes/) |
Solo eidos los compone |
| Persistencia del theme | Eidos (toDocument()) |
Solo eidos lo serializa |
| TSC scope axes (color, state, variant, size, event) | Eidos (refieren a attrs morfo-emitted) | Hardcoded en TSC porque son axes del DOM contract |
La frase canónica
Morfo declara el contrato. Eidos declara el theming. El DOM los conecta.
Esto NO es un compromiso. ES el diseño. La pureza de morfo (TS declarativo, sin runtime, sin imports de capas visuales) DEPENDE de que el theming viva fuera.
Qué propuestas futuras DEBEN rechazarse citando esta sección
- "Vamos a poner los tokens visuales del Toggle en su morfo así morfo es source of truth de todo" — viola §9 y la regla 2-de-3.
- "Vamos a hacer que TSC valide
color:affirmimportandotoggleMorfo.data['data-color'].values" — viola regla #6 (eidos no importa internals de morfo en TS). - "Vamos a meter
variant: 'solid' | 'outline'en el morfo del Toggle" — viola 2-de-3 (variants solo las consume eidos). - "Vamos a definir
sizeen el morfo con sus 6 valores canónicos" — viola 2-de-3 (los 6 sizes son sistema visual, solo eidos los materializa con tokens coordinados).
Si la propuesta tiene mérito, lo correcto es actualizar el doc
canónico (active_architecture.md §9) ANTES de aplicar el cambio.
No al revés.
Lo que SÍ debería entrar en morfo respecto al theming
- Un componente que expone
colorcomo prop → debe declarardata-color.values: ['primary', 'affirm', ...]en su morfo. Esos values son cross-layer (eidos targetea, soma emite, sema podría referenciar). - Un componente que expone
state(open/closed) → declaradata-state.values: ['open', 'closed']. Igual. - Un componente que añade attrs visualmente puros (
data-variant,data-size) que NADIE más necesita → NO van en morfo, los añade el wrapper eidos directamente.
Consistencia con los docs canónicos
Esta sección no introduce doctrina nueva. Recoge y consolida lo que ya estaba disperso en:
src/uix/active_architecture.md§3 (Morfo = único punto de articulación cross-layer), §7 #6 (Eidos no importa internals), §7 #12 (regla 2-de-3), §9 (tokens pertenecen a Eidos).src/uix/README.md§2 (Eidos = capa visual con tokens), §4 (no es design system clásico), §5 (Eidos consume DOM y data-*).- Esta misma
THEMING.md§1 (Mental model) y §7 (TSC).
Si alguno de esos docs canónicos contradice esta sección, el doc canónico gana. Esta sección consolida; no decide.
2. Las capas del CSS de Eidos
src/uix/eidos/index.css es el entrypoint (la fuente de verdad del orden es
el propio archivo). Importa, en orden:
1. generated/base.css ← foundation + recipe tokens + @font-face (generado)
2. archetypes.css ← reglas transversales por data-archetype
3. events.css ← reacciones a data-event-* (sema)
4. components/{c}/{c}.css ← recipes agregados: layout primitives + spin-field
Fuera del entrypoint pero parte de la capa visual:
- Recipes code-split: la mayoría de componentes NO están en
index.css— cada.svelteimporta su propio CSS y Vite emite un chunk por componente. La AUSENCIA de un@importes deliberada; re-añadirlo duplicaría la carga. - Partials compartidos (
lib/menu-indicator.css): los importa el componente que los usa, no el entrypoint. - Themes: bloques CSS inyectados en runtime por
ActiveEidos(uix-eidos-theme), no un@importestático.themes/fonts.cssquedó superseded — los@font-faceviven enEidosConfigy salen engenerated/base.css.
Por qué este orden importa
generated/base.cssdeclara tokens (no estiliza). Si los recipes se cargan antes, no tienen los tokens disponibles.archetypes.csssetea baseline interactiva (cursor, hover, focus ring). Recipes específicos sobrescriben.events.cssreacciona adata-event-*conanimation: @keyframes(notransition) porque las señales son transitorias y la animación debe completar independiente del lifetime de la señal.- Recipes específicos vienen al final → mayor cascade priority en conflictos de igual specificity.
Qué hace cada layer concretamente
| Layer | Tipo | Cuántas reglas | Propósito |
|---|---|---|---|
themes/fonts.css |
@font-face |
4-12 | Cargar fuentes |
generated/base.css |
:root + algunos [data-{c}] blocks |
4400+ declaraciones | Foundation tokens (scale, primitive, color, size, density, typography, recipe tokens) |
archetypes.css |
[data-archetype='X'] selectors |
11 archetypes | Estilo baseline transversal (trigger, overlay, content, indicator, thumb, track, close, action, item, option, focus) |
events.css |
[data-event-*] selectors + @keyframes |
9 keyframes + 15 rules | Reactions perceptivas (announce pulse, dismiss fade, commit settle, etc.) |
lib/menu-indicator.css |
Partial | 1 selector | Indicator alignment compartido entre menus |
components/{c}/{c}.css |
[data-{c}-*] selectors |
varía | Recipe específico del componente |
Reglas para tocar cada layer
fonts.css: añade@font-face. No declara tokens, no estiliza.generated/base.css: NUNCA editar a mano. Es output del generador. Para cambiarlo, editalib/recipes/base.tsolib/themes/base.tsy correnpm run generate:eidos-css.archetypes.css: añade entradas sólo si el archetype está declarado en algún morfo. Reglas de specificity baja (un atributo).events.css: añade reactions sólo para señales que sema emite. Usaanimation: @keyframes, NOtransition. Leedata-event-intent(signal-bound), NUNCAdata-intent(state-bound).components/{c}/{c}.css: el dueño es la persona que mantiene el componente. Sigue la convención de naming (sección 6).
3. Las 7 capas de tokens
Eidos compone el color final de un elemento atravesando 7 niveles de indirección. Cada nivel sirve un propósito distinto:
┌─ Capa 1: --scale-{name}-{step} :root (estable)
│ Escalas físicas Radix (12 steps + alpha): --scale-teal-9 = #12a594
│
├─ Capa 2: --primitive-{role}-{step} :root (estable)
│ Mapeo role → scale: --primitive-affirm-9 = var(--scale-teal-9)
│
├─ Capa 3: --color-{role}-{slot} :root (estable)
│ Slot semántico: --color-affirm-solid = var(--primitive-affirm-9)
│
├─ Capa 4: --{component}-{role}-{slot} :root (estable)
│ Per-component alias: --button-affirm-solid = var(--color-affirm-solid)
│ (NOTA: drop del segmento "color-" en 2026-05-27)
│
├─ Capa 5: --{component}-palette-{slot} [data-{c}] (DINÁMICA)
│ Palette dinámica por instancia: cambia con data-color
│
├─ Capa 6: --{component}-{variant}-{slot} [data-{c}] (host) (DINÁMICA)
│ Combinación de variante × palette
│
└─ Capa 7: --_{component}-{slot} [data-{c}] (privado)
Token privado consumido por el recipe CSS directamente
Reglas de scope:
- Capas 1-4 son constantes →
:root. - Capa 5 cambia por instancia →
[data-{c}]y[data-{c}][data-color='X']. - Capa 6 depende de la 5 → DEBE estar en
[data-{c}](TSC lo enfuerza). - Capa 7 es privada → siempre en
[data-{c}].
Por qué tantas capas
No es accidental. Cada salto sirve un punto de extensión:
| Capa | Override permite | Ejemplo de uso |
|---|---|---|
| 1 | Cambiar la escala física Radix | Brand quiere su propio teal |
| 2 | Cambiar qué escala mapea un role | "Affirm" usa green en vez de teal |
| 3 | Cambiar slot mapping per role | "Solid" del affirm usa step 10 en vez de 9 |
| 4 | Cambiar token component-specific | Toggle quiere su affirm distinto del global |
| 5 | El runtime per-instancia | <Toggle color="affirm" /> cambia el palette |
| 6 | Combinar variant × color | Solid variant del toggle con affirm color |
| 7 | Recipe-internal | El recipe decide qué token interno usa para qué |
En la práctica, la mayoría de las apps SÓLO tocan las capas 1-3 (brand customization). Las capas 4-7 son del catálogo de componentes.
Cuándo crear un token nuevo en cada capa
- Capa 1 (scale): una app rara vez; un tema de marca SÍ trae o amplía su propia paleta (§25.7). Las 33 escalas por defecto cubren el caso general.
- Capa 2 (primitive): rara vez. Sólo si añades un role canónico nuevo (lo cual cambiaría el book canon — no lo hagas).
- Capa 3 (color): si añades un nuevo
{slot}(raro). El inventario canónico de slots y su step por defecto viven en el código — única fuente:COLOR_ROLE_SLOTS(lib/config-types.ts, con el porqué de cada slot en su JSDoc) +DEFAULT_COLOR_ROLE_SLOT_STEPS(lib/render-css.ts). No se copia la lista aquí: ya divergió dos veces (border-hover retirado; bg2/separator/text-strong añadidos). - Capa 4 (component-color): añadiendo color support a un componente
nuevo. Se genera automáticamente por
lib/recipes/base.ts. - Capa 5 (palette): cuando el componente acepta
data-colorprop y necesita un palette dinámico. TSCscope: 'host'+ overridesscope: 'color:X'. - Capa 6 (variant): cuando una variant (
solid,outline, etc.) combina palette + algo específico. TSCscope: 'host'. - Capa 7 (private): el recipe lo consume. Convención: prefijo
_.
4. Los 9 roles canónicos de color
Los roles vienen del libro Diseñando lo que ocurre. SON CANON. NO inventes nuevos.
HIERARCHY (no evaluative) INTENT (evaluative)
───────────────────────────── ───────────────────────────
primary — brand main affirm — turning ON something positive
secondary — brand support fulfill — completion / success
tertiary — brand tertiary risk — moderate negative consequence
neutral — gray default threat — active negative consequence
loss — irreversible negative outcome
Reglas estrictas:
- Los 9 nombres son los únicos válidos. NO uses
success,warning,danger,info— esos pertenecen a otros modelos (Bootstrap, etc.). primary/secondary/tertiaryson jerárquicos: usar cuando la diferencia es "más vs menos prominente". Sin carga evaluativa.neutrales el default. Sin carga semántica.affirm/fulfill/risk/threat/lossson evaluativos: comunican qué pasa con la acción.- Si
intent === 'neutral',color(hierarchy override) puede aplicar. Siintentes evaluativo, elintentGANA ycolorse ignora.
Mapping a escalas físicas (en theme base): la asignación vigente vive
en el código — única fuente: THEME_BASE_COLOR_ROLES
(lib/themes/base.ts), con el porqué de cada elección en sus comentarios
(p. ej. tertiary: 'indigo' es un slot RESERVED de jerarquía que ningún
componente consume aún; loss: 'plum' para no colisionar con
primary: 'purple'). Esta tabla se copió aquí dos veces y divergió las dos
(primary, risk) — por eso ahora es un puntero.
Convención ≠ autoría.
CANONICAL_INTENT_SCALES(lib/config-types.ts) es la convención del libro para auto-derivar intents de una paleta (identidad = step 9; p. ej.risk→amber). El theme base es autoría y puede desviarse (p. ej.risk: 'orange'). La jerarquía (primary/secondary/tertiary) la elige siempre el tema. Modelo completo en §25.
La paleta son 33 escalas de 12 steps + 12 alpha = 24 tokens c/u
(792 tokens --scale-* — el grueso del bloat del foundation). Sobre
ella, los 9 roles aliasan vía --primitive-{role}-{step} (9 × 24 =
216 primitives).
Por qué 9 roles y no 4 (como shadcn) o 14 (como Mantine)
Los 9 son el resultado del análisis perceptivo del libro:
- 3 hierarchy roles cubren la dimensión "prominencia visual".
- 1 neutral cubre el default sin carga.
- 5 intent roles cubren las cinco valencias evaluativas distintas.
Cualquier sistema con menos pierde resolución perceptiva. Cualquier sistema con más cae en redundancia (success vs fulfill, danger vs threat — no son lo mismo).
Subset por componente
Cada componente expone su propio subset de los 9. Ejemplos:
| Componente | Subset | Excluye |
|---|---|---|
| Toggle | primary, secondary, neutral, affirm, risk, threat | fulfill, loss (no aplica) |
| Button | los 9 | — |
| Badge | primary, secondary, neutral, affirm, fulfill, risk, threat, loss | tertiary (no canónico) |
Por qué subsets: un toggle no es completion ni irreversible loss. Exponer fulfill/loss en su API sería semánticamente incorrecto.
5. El canon de sizes
xxs · xs · sm · md · lg · xl · xxl | full
───────────────────────────────────── ───────
6 sizes canónicos (físicos) 1 size de layout
md es el default. full no es físico — es semántica de layout
(100% / 100vw / 100dvh según contexto). No genera tokens fijos.
Cada size canónico genera tokens coordinados:
--size-md-control-height: 36px
--size-md-font-size: 16px /* = var(--font-size-md), 1:1. Bundle EN ADOPCIÓN (decisión Fase D 2026-07-02; pilot: toggle) */
--size-md-font-line-height: 1.45
--size-md-icon-size: 18px /* = --icon-size-md */
--size-md-padding-inline: 12px
--size-md-padding-block: 8px
--size-md-gap: 8px
--size-md-radius: 6px
mdes uno solo (1:1 — override 2026-06-17, ver abajo): el bundle de control--size-md-font-size=var(--font-size-md)= 16px, idéntico a la escala tipográfica. La doctrina previa de "dosmd" (control 14px compacto vs body 16px) quedó revocada — el texto de control sigue la escala tipográfica 1:1.
Regla clave: md NO cambia por viewport
Lo responsive decide qué size activo se usa, NO redefine los
tokens. Si tu Toggle en mobile usa sm y en desktop md, ambos
tokens están disponibles y el wrapper elige cuál.
<!-- Correcto -->
<Toggle size={{ base: 'sm', md: 'md' }} />
<!-- Incorrecto -->
@media (max-width: 768px) {
:root { --toggle-height-md: 32px; } /* NO redefinas el token del size por viewport */
}
Subset por componente
Como con color, cada componente expone su subset de sizes que su recipe soporta. Categorías:
| Categoría | Subset | Ejemplos |
|---|---|---|
| Form controls + text inputs | xs..xl |
input, select, switch, slider, checkbox |
| Nav controls | xs..lg |
breadcrumb, pagination, tag-group, toolbar |
| Composed panels | sm..lg |
calendar, date-picker, file-upload, stepper, tooltip |
La categorización vive en
web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md §12.8.
Derivación contenedor→parte: cap en md (norma 2026-06-19)
Cuando una parte deriva su size de su contenedor (p. ej. Dialog.Close
hereda el size del dialog), la parte sigue el size del contenedor solo en los
pasos por debajo de md; en md y por encima (lg / xl / full) cap a
la densidad normal md. Un contenedor más ancho — o full — NO engorda sus
controles: full es layout (llena el viewport), no un tamaño de control mayor.
| size del contenedor | size de la parte |
|---|---|
xs / sm |
xs / sm (sigue el paso) |
md / lg / xl / full |
md (normal) |
Coincide con las referencias: Radix separa size (densidad) de width (full);
Mantine fullScreen ignora size; Material 3 full-screen es un tipo de
layout (top app bar) con controles estándar. Ninguno agranda los controles por
ser el dialog full.
Primer consumidor: Dialog.Close — components/dialog/context.ts publica el size
del dialog y el Close lo deriva. Una prop size explícita en la parte siempre gana.
Mapeo size→fuente: 1:1 universal (override 2026-06-17)
Supersede los arquetipos
control/compact/densede la Fase 7. El usuario decidió que un framework de grado empresarial necesita una sola fuente de verdad: el texto de control sigue la escala tipográfica 1:1 —font-size-{size}=var(--font-size-{size})— en TODOS los componentes. Así, cambiar--font-size-mda 15px re-adapta el tema entero sin tocar un solo recipe. Elmd-control es 16px (ya NO 14: la doctrina "control compacto md=14" queda revocada).
Escala (xs/sm/md/lg/xl/xxl): 12 · 14 · 16 · 18→20 · 24→28 · 32→48
(lib/primitives/typography.ts; lg/xl/xxl fluidas clamp()).
Recipes llevados a 1:1 (2026-06-17): button, badge, breadcrumb, calendar, pagination, radio-group, toolbar, file-upload, tag-group, stepper, toggle, tooltip — más toda la familia de campos (field, spin/date/time/color-field, search/password-field, select, editable, tags-input), ya 1:1 desde el sprint de campos.
Excepciones legítimas (NO son texto-de-control → no aplican 1:1):
- avatar/marker — la fuente es la inicial dentro del círculo, escalada al diámetro (24→96px): glifo proporcional, no control.
- accordion — el trigger es un encabezado de sección: usa la escala de prosa
--text-N-size(xs→text-2 … full→text-6), progresión propia coherente. - words / palabras / chronos — tracks WIP excluidos.
El bundle --size-* (arriba) está en adopción (decisión Fase D, 2026-07-02 —
antes llevaba un año huérfano). Patrón establecido por el pilot (toggle,
lib/recipes/base.ts): la recipe consume --size-{k}-* donde su valor ES la
coordenada canónica (height, font-size — cadenas de alias idénticas, cero cambio
visual) y conserva su propio valor donde desvía deliberadamente (px/gap más
prietos que el padding del bundle) — la desviación queda visible en vez de
enterrada en una re-declaración paralela. El barrido al resto del catálogo es el
workstream abierto; ya estaba realineado al 1:1 (2026-06-29:
--size-md-font-size = var(--font-size-md) = 16px).
Regla dura (guard de coherencia) — recipe-css-contract.test.ts:
Ningún token
font-size-*/icon-size-*de recipe puede ser un literal px/rem — DEBE referenciar--font-size-*/--icon-size-*(si no, el texto deja de seguir la escala tipográfica,--scalingyapplyTypeScale()).
Cierra el hueco que el guard anterior (solo-CSS) dejaba — los valores de recipe-token
se vuelven custom properties, no la propiedad font-size:. (words/palabras/chronos
excluidos — track activo.) El refactor a consumir el bundle del arquetipo (en vez de
re-declarar el mapeo) queda como follow-up; el guard es lo que impide la deriva.
Campos: input 1:1 + label un paso por debajo (override 2026-06-17)
La familia de campos aplica el 1:1 universal (arriba) al input, y añade una regla propia para el label:
- Input/control → 1:1 (como todo control): 12 · 14 · 16 · 18→20 · 24→28.
- Label → un paso tipográfico por debajo del input: 10 · 12 · 14 · 16 · 18. El label es el único elemento del sistema que baja un escalón a propósito — jerarquía label↔input, no la deriva colapsada que se rechazó.
| Recipe | input | label |
|---|---|---|
field (genérico) |
control-font-size-* 1:1 |
label-font-size-* un paso abajo → cubre todo lo que envuelve <Field> |
spin-field (number/css), date/time/color-field |
font-size-* 1:1 |
date/time/color: label propio vía CSS calc; spin: label del Field |
search-field, password-field, select, editable, tags-input |
font-size-* 1:1 |
label del Field |
Label de campos segmentados (solo date/time/color tienen [data-X-field-label]):
font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm))) — el input (1em
heredado) menos un paso de escala (md−sm = 2px, tokenizado). El Field genérico usa
tokens label-font-size-* por-size.
No tocados (ya coherentes): pin-input (cell-font-size-* ya escala), combobox
(sin fuente propia). Excluidos: words, chronos. Los no-campos (button/calendar/
badge…) conservan su arquetipo.
Pendiente: en lg el label segmentado (calc → 18) y el del Field (token → 16)
divergen 2px porque la lg del input es fluida (18→20). En xs/sm/md (fijos)
coinciden. Resolver dando tokens por-size a los segmentados, o quitando la lg fluida
del input de campo.
Escala tipográfica ↔ escala de iconos
Son dos escalas paralelas — --font-size-{name} (lib/primitives/typography.ts)
y --icon-size-{name} (lib/primitives/static.ts → STATIC_ICON.size) — ambas
escaladas por densidad (× --scaling). No son independientes: el icono
acompaña al texto.
La regla óptica se validó por ojo, no por fórmula (banco de pruebas
/uix/icon-scale-study):
el icono pesa un punto por encima del texto — ≈ font + 2 en los tamaños de
cuerpo, creciendo hacia ≈ line-height en los grandes (un icono atado a
font-size abajo, a la line-height arriba). No es una constante:
| size | --font-size (px) |
line-height (px) | --icon-size (px) |
icon − font |
|---|---|---|---|---|
| xxs | 10 | 15 | 12 | +2 |
| xs | 12 | 18 | 14 | +2 |
| sm | 14 | 20 | 16 | +2 |
| md | 16 | 23 | 18 | +2 |
| lg | 20 | 27 | 20 | 0 |
| xl | 24→28 | 34 | 32 | +4 |
| xxl | 32→48 | 50 | 52 | +4 |
(Los headings lg–xxl son fluidos clamp(); la columna font-size muestra el
max desktop. El salto de iconos lg 20 → xl 32 es fiel al salto del texto
20 → 28 — no hay tamaño intermedio porque la tipografía tampoco lo tiene.)
Por eso un componente nunca inventa tamaños de icono en px: declara
var(--icon-size-{name}) y hereda esta correlación. El size canónico ya
empareja ambos ejes (--size-md-font-size + --size-md-icon-size). Matices por
arquetipo:
- Iconos de control siguen el font 1:1 (override 2026-06-17): el
icon-size-{size}de cada recipe de control referenciavar(--icon-size-{size}), paralelo al font 1:1. La escala de iconos preservaicon ≈ font+2en cuerpo (md: font 16 → icono 18). Aplicado a button + search-field. Excepciones:password-field— suicon-size-*NO es un glifo sino la caja del botón visibility-trigger (el glifo es el 65%), control-coupled a propósito;radio-cards— el icono sigue el font del título de la card. - Densidad ⊥ tipografía (compact/comfortable/spacious): la densidad escala SOLO
layout —
space(× --density-space-scale) +control-height(× --density-control-scale).--font-size-*y--icon-size-*NO llevan densidad — solo el zoom global--scaling(Radix-parity) los toca. Consecuencia: a 1:1 el icono queda acoplado al texto en toda densidad (font 16 / icono 18 constantes; solo la caja del control se aprieta: 32.4 / 36 / 40.3). Por eso un icono dimensionado desde--control-height-*(density-coupled) se desacopla del texto — es un anti-patrón salvo que el elemento sea un control (p. ej. el trigger del password-field). - Cards / títulos (radio-cards, empty-states): el icono acompaña el font del
título, nunca un tamaño inflado a mano — p. ej. radio-cards =
16/16/18/18/20(sigue su título). Para destacar más se sube el font del título (el icono lo sigue), no se infla el icono.
Cambiar la escala = editar STATIC_ICON + components/icon/create-icon.ts +
regenerar (npm run generate:eidos-css). Nada más la consume en crudo.
6. Convenciones de naming
Tokens públicos (consumibles)
--{prefix}-{slot}
Donde {prefix} es uno de:
| Prefix | Significado | Ejemplo |
|---|---|---|
--scale-{name}-{step} |
Escala física Radix | --scale-teal-9 |
--primitive-{role}-{step} |
Role → step | --primitive-affirm-9 |
--color-{role}-{slot} |
Color role × slot | --color-affirm-solid |
--font-{kind}-{key} |
Tipografía | --font-family-primary |
--size-{key}-{slot} |
Size primitive | --size-md-control-height |
--space-{n} |
Spacing scale | --space-3 |
--radius-{key} |
Radius scale | --radius-md |
--border-width-{key} |
Border-width scale (lineal none·thin·medium·thick·heavy = 0/1/2/3/4) — §35 |
--border-width-thick |
--ring-inset-width |
Default ancho del inset-ring (anillo interior box-shadow) — §35 |
--ring-inset-width |
--shadow-{n} |
Shadow scale (gota) | --shadow-3 |
--shadow-inset-{key} |
Inner-shadow / recessed (subtle·deep, mode-aware) — §35 |
--shadow-inset-subtle |
--blur-{key} |
Blur scale (backdrop/frost, none·sm·md·lg·xl·xxl) — §35 |
--blur-lg |
--depth-{plane}-translucency |
Frost opacity por plano = función de la elevación (más alto = más opaco) — §29 | --depth-modal-translucency |
--gradient-{name} |
Gradiente nombrado themeable — token o derivado de rol vía buildGradient/applyGradients (6º builder) — §29 |
--gradient-aurora |
--gradient-angle-{dir} |
Dirección de gradiente (8 brújulas) — §35 | --gradient-angle-to-r |
--breakpoint-{key} |
Breakpoint responsive (fuente = ActiveDom) — §35 | --breakpoint-md |
--z-index-{key} |
Z-index layer (depth planes) | --z-index-modal |
--z-index-overlay-{key} |
Flat overlay micro-band (portaled overlays + modals) — §35 | --z-index-overlay-floating |
--opacity-{key} |
Opacidad — escala dual numérica (0..100) + semántica (disabled·muted·…) — §35 |
--opacity-disabled |
--tracking-{key} |
Letter-spacing scale (incl. caps para MAYÚSCULAS) — §35 |
--tracking-caps |
--leading-{key} |
Line-height scale | --leading-ui |
--duration-{key} |
Motion duration | --duration-fast |
--ease-{key} |
Motion ease | --ease-out |
--style-{name}-* |
Typography named style | --style-h1-font-size |
--{c}-{slot} |
Component recipe token | --toggle-height-md |
--{c}-{role}-{slot} |
Component color | --toggle-affirm-solid |
--{c}-palette-{slot} |
Component palette runtime | --toggle-palette-solid |
Tokens privados (componente-internal)
--_{c}-{slot}
El prefijo _ significa: NO consumes esto desde fuera del recipe del
componente. Es interno. Ejemplo:
[data-toggle] {
--_toggle-bg: var(--toggle-solid-bg); /* privado */
--_toggle-on-bg: var(--toggle-palette-solid); /* privado */
}
Reglas estrictas
- Todos los public tokens del Eidos llevan el prefijo
--sin sub-prefijo de capa. Razón: clarity en debug. Ver--toggle-bgy sabes que es Eidos. Ver--bgy no sabes de dónde viene. - NUNCA usar
--eidos-como prefijo. La capa ya está implícita en el path$uix/eidos/components/{c}. - NUNCA usar
--soma-ni--air-ni--terra-. Esas capas son muertas o no poseen tokens. - Los component tokens siguen el patrón
--{component-kebab}-.... El componente kebab es el nombre del directorio. - No abreviar nombres de componente.
dropdown-menuno se vuelveddmenu. La authorship clarity vale 6 chars. - NO incluir el segmento "color-" intermedio en tokens de color.
--toggle-affirm-solid(correcto),--toggle-color-affirm-solid(deprecado 2026-05-27). - Slots siguen vocabulario fijo: para las capas 3-5 el inventario
canónico es
COLOR_ROLE_SLOTS(lib/config-types.ts— ver §3, capa 3; no se copia aquí). Para las capas 6-7 (recipe-level):bg, fg, border, on-bg, on-fg, on-border, hover-bg, on-hover-bg.
Tokens generados vs autoría
Tokens en generated/base.css son output. Para añadir uno nuevo,
editas:
lib/themes/base.tspara primitives, scales, theme variants.lib/recipes/base.tspara tokens de componente.
Y corres npm run generate:eidos-css.
7. Token Scope Contract (TSC)
Movido a
TSC.md(canon visual, E2). El Token Scope Contract —scopes disponibles, las tres formas de declarar un token, el álgebra descopeCovers, la detección de colisión cross-axis, multi-part scope y cross-recipe composition (v2.2), y el pipeline de 5 defensas— vive ahí como su propio capítulo. Resumen: el TSC decide DÓNDE se emite cada token (raíz, por componente, por color, por evento) y valida al generar que toda dependencia esté disponible en el scope del consumidor.
8. Cómo añadir un componente nuevo
Movido a
THEMING_GUIDE.md(guía E4). Los pasos para añadir un componente nuevo —decidir qué tokens necesita, recipe, scope y validación— viven ahí junto a la guía de definir un theme.Qué sistemas transversales DEBE consumir la recipe (state-layer, focus, elevación tokenizada, tipografía 1:1, opacidad, motion, ejes lógicos) es su propio canon:
RECIPE_CONTRACT.md, vigilado por las reglas R-4.x decomponent-audit.
9. Cómo definir un theme
Movido a
THEMING_GUIDE.md(guía E4).
10. Cómo overridear tokens en runtime
ActiveEidos.setCssVariables() permite override runtime contract-aware:
activeEidos.setCssVariables({
'--color-primary-solid': 'rebeccapurple',
'size-md-control-height': '40px', // sin -- también vale
'shadow-3': '0 10px 28px rgb(20 20 20 / 0.16)'
});
Eidos:
- Valida cada nombre contra
getCssContract(). Tokens fuera del contrato lanzan error en modostrict(default). - Renderiza transacionalmente: primero render + valida, luego
reemplaza el
<style>block runtime. - Aplica los overrides bajo
:root(o el selector que pases).
Para variables fuera del contrato (locales de la app):
activeEidos.setCssVariables(
{ '--my-app-custom': 'value' },
{ strict: false }
);
Builders de sistema completo
Por encima de setCssVariables hay dos builders que derivan un sistema entero desde una
semilla y lo escriben como bloque gestionado (siguen el tema activo light/dark):
eidos.applyColorScheme(seed, opts)— deriva las 33 escalas + 9 roles desde un color de marca (buildScheme).clearColorScheme()revierte.eidos.applyTypeScale(seed, opts)— deriva los 8--font-size-*desde un ratio modular + base (buildTypeScale), opcionalmente fluido (ratioMax).clearTypeScale()revierte.
Ambos son puros en eidos/lib (build-scheme / build-type-scale) + un método de
aplicación en ActiveEidos. Demos en vivo: /temas/color y /temas/tipografia.
11. Bundle strategy + eidos:purge
generated/base.css contiene tokens de TODOS los componentes del
catálogo (~95 components). En producción una app típica usa 5-20.
El tool
npm run eidos:purge -- \
--src 'src/**/*.svelte' \
--src 'src/**/*.ts' \
--src 'src/**/*.css' \
--output dist/eidos.purged.css \
--verbose
Cómo decide qué mantener
- Foundation siempre kept: scale, primitive, color, size, opacity, z-index, shadow, border, radius, space, density, motion, icon, typography, layout. ~1100 tokens (~95 KB raw / ~11 KB gzip).
- Source-scan tokens: cada
var(--XXX)y--XXX:declaración encontrada en source →XXXpinned. - Component-import detection: cada
from '...components/{c}'→ recipe completo de{c}pinned. - Data-attr detection: cada
data-{c}=(filtrado contra registry canonical de recipes) → recipe completo de{c}pinned. - Cierre transitivo: si X pinned y X→
var(--Y), Y pinned. Iteración hasta fixed point.
Resultados medidos
| Perfil | Components | Raw before | Raw after | Reducción | Gzip after |
|---|---|---|---|---|---|
| Minimal (toggle+button+badge) | 3 | 217.7 KB | 97.4 KB | −55% | 11.1 KB |
| SaaS típico (10 components) | 10 | 217.7 KB | 116.3 KB | −46% | 13.6 KB |
| 5 páginas demo UIX | 7 | 217.7 KB | 107.3 KB | −51% | 12.4 KB |
| Exhaustivo (todos) | 62 | 217.7 KB | ~217 KB | −0.4% | ~25 KB |
Piso arquitectónico: ~95 KB raw / ~11 KB gzip (foundation que toda app necesita).
Cuándo usarlo
- En producción: SIEMPRE. Integra en tu build pipeline.
- En dev: opcional. El raw 226 KB es aceptable para iteración local.
- En SSR: pre-purge una vez por build, no per-request.
Limitaciones conocidas
- Dynamic component selection: si tu app importa componentes
dinámicamente (
await import(...)), el scanner los puede perder. Mitigación: pasa los nombres via--keep my-component. var()en strings dinámicos: si construyesvar(--${name})en runtime, el scanner no lo ve. Mitigación: declara los nombres estáticamente en algún archivo escaneable.
12. Herramientas de validación
| Tool | Comando | Qué valida |
|---|---|---|
| morfo:check | npm run morfo:check |
DOM contracts vs morfo declarations (Playwright walk de 107 demos) |
| eidos-lint | node scripts/eidos-lint.ts {c} |
Recipe CSS selectors vs morfo enum values |
| eidos-lint-all | node scripts/eidos-lint-all.ts |
Igual, todos los componentes |
| TSC validation | npm run generate:eidos-css (implícito) |
Scope algebra + cross-axis collision detection |
| recipe-css-contract | npm test -- recipe-css-contract |
Recipe tokens consumidos + TSC v2 scenarios (17 tests) |
| component-api-contract | npm test -- component-api-contract |
Public API surface por componente |
| component-visual-attrs | npm test -- component-visual-attrs |
Visual data-attrs que el wrapper emite |
| generated-css | npm test -- generated-css |
Estructura del CSS generado |
Pipeline de validación recomendado pre-commit
npm run generate:eidos-css # Si tocaste recipes/themes
npm test -- src/uix/eidos # 99/99 tests
npm run check # TS check
npm run morfo:check # DOM contracts (requiere dev server)
node scripts/eidos-lint-all.ts # CSS drift safety net
13. Integración con Sema (event:* scope)
⚠️ Superseded. El modelo de motion vigente es el de dos momentos documentado en
eidos-motion.md(F1–F7): el momento--event(la firma perceptiva) se declara enmotion.signaturesy se genera como CSS contradata-event-*directamente — sin el scope TSCevent:*ni eldata-motion-refque esta sección discutía. El motor (EngineMotion) es un servicio enarts/motion(uix.motion). El cuerpo original (contexto histórico de la decisión) vive enTHEMING_CHANGELOG.md §13.
14. Motion
El sistema de motion de Eidos —el modelo de dos momentos (--event
perceptivo durante el hold de una señal + --state para transiciones
persistentes), el motor EngineMotion (reubicado a arts/motion, expuesto como
uix.motion y consumido por soma y eidos) y cómo se autora un preset— es su
propio sistema y vive en eidos-motion.md. Roadmap F1–F7
implementado (2026-06-04). Esta sección cubre sólo lo que toca al theming.
Nota de estado. Versiones previas de este doc describían motion como "deferred" y daban el
data-motion-ref/ el "TSCevent:*scope" (§13) como su futuro. Eso quedó obsoleto: la firma perceptiva migró al registro designatures(no aevents.css) y el motor es hoy un servicio enarts/motion.eidos-motion.mdes la referencia canónica y actual.
Superficies arrastrables: el lift de "pickup"
Cuando el usuario agarra y arrastra una superficie, ésta debe subir hacia él (profundidad: "lo he cogido"). Es un cue transversal — NO específico de un componente — así que el factor de escala es un token global de motion, no un literal por recipe:
| Token | Valor | Uso |
|---|---|---|
--motion-scale-lift |
1.02 |
escala de pickup al arrastrar (el único >1 de la familia --motion-scale-*) |
Patrón (cualquier draggable lo aplica igual) — gateado por el data-attr de
arrastre que su morfo declara (data-dragging, data-grabbed, …), emparejado
con una elevación de sombra, y suprimido bajo reduced-motion:
/* float-panel, un thumb de slider arrastrándose, un item sortable, un drawer… */
[data-x][data-dragging] {
scale: var(--motion-scale-lift); /* sube hacia el usuario */
box-shadow: var(--…-shadow-active); /* + eleva la sombra */
}
@media (prefers-reduced-motion: reduce) {
[data-x][data-dragging] { scale: 1; transition: none; }
}
Reglas:
scale(notransform) para componer con la posición, que viaja portranslate(propiedades distintas) → el lift no pelea con el arrastre 1:1.- El cambio de
scalese transiciona (lift al agarrar / settle al soltar); durante el move queda estático (compuesto, sin coste por frame). will-change: translate, scalemientras dura el gesto.- Un theme retematiza el lift en
STATIC_MOTION.scale.lift(primitives/static.ts) — todos los draggables lo heredan. No redefinir el 1.02 por componente.
Hoy lo consume float-panel ([data-float-panel-content][data-dragging]); un
slider/sortable que añada arrastre debe leer el MISMO token, no inventar el suyo.
15. Comparación con librerías de referencia
Movido a
THEMING_NOTES.md(E3). La comparación de eidos con Radix, Ark, Mantine y otras vive ahí, junto al FAQ de decisiones.
16. Anti-patterns que NO debes cometer
A. Declarar tokens derivados en :root
// ❌ INCORRECTO — el bug del Toggle pre-TSC
'palette-solid': { value: '...', scope: 'host' },
'solid-on-bg': 'var(--my-component-palette-solid)' // scope 'root' implícito
TSC lanza error al regenerar. Fix: scope: 'host' en el consumer.
B. Usar nombres con segmento "color-" redundante
// ❌ DEPRECATED (2026-05-27)
'color-affirm-solid': 'var(--color-affirm-solid)'
// ✅ CORRECTO
'affirm-solid': 'var(--color-affirm-solid)'
C. Inventar roles fuera del canon
// ❌ NO — success/danger/warning/info son de otros modelos
'success': 'green',
'danger': 'red'
// ✅ Usa los 9 canónicos
'fulfill': 'green', // success → fulfill
'threat': 'red' // danger → threat
D. Definir media-queries que cambien tokens canónicos
/* ❌ NO — md cambia significado por viewport */
@media (max-width: 768px) {
:root { --size-md-control-height: 32px; }
}
/* ✅ Componente elige qué size aplica por viewport */
<Toggle size={{ base: 'sm', md: 'md' }} />
E. Importar $libs/dom directamente en eidos
// ❌ NO
import { foo } from '$libs/dom';
// ✅ Eidos consume vía ActiveEidos.dom
const eidos = ActiveEidos.require();
eidos.dom.apply(...);
F. Tocar generated/base.css a mano
Es output. Cualquier cambio se sobrescribe al regenerar. Si necesitas
cambiar algo, edita lib/themes/base.ts o lib/recipes/base.ts.
G. Crear escalas físicas sueltas dentro de una app
Las 33 escalas por defecto cubren las paletas razonables. Un tema de marca SÍ trae su propia paleta como escalas (§25.7) — eso es legítimo. Lo que NO debes hacer es añadir una escala one-off dentro de una app cuando remapear un role a una escala existente ya resuelve el caso.
H. Re-exportar entre layers
// ❌ NO — eidos no re-exporta soma
export * from '$soma/components/toggle';
// ✅ Cada layer expone su propio API
17. FAQ — decisiones polémicas
Movido a
THEMING_NOTES.md(E3).
Referencias
src/uix/eidos/README.md— el doc de arquitectura vivo (esta info está duplicada parcialmente; este doc es la canónica).src/uix/eidos/THEMING_AUDIT_2026-06-01.md— la auditoría del theming (el journal de cómo llegamos aquí).src/uix/eidos/eidos-motion.md— el sistema de motion (modelo de dos momentos, F1–F7; ver §14).src/uix/eidos/lib/config-types.ts— la fuente de verdad del TSC type.src/uix/eidos/lib/render-css.ts— el generador (parsing, scope algebra, cross-axis detection).src/uix/eidos/lib/recipes/base.ts— el catálogo de tokens per-component.src/uix/eidos/lib/themes/base.ts— el theme base (primitives + semantics + themes).scripts/eidos-purge.ts— el purge tool.src/uix/eidos/recipe-css-contract.test.ts— el test guard (17 tests).src/uix/active_architecture.md— el contexto UIX completo.src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md— la doctrina sema/perceptual (fuente del canon de 9 roles).
18. Cobertura universal de TSC
Movido a
TSC.md. Las extensiones v2.2 (multi-part scope y cross-recipe composition) que llevan el TSC a cobertura universal viven con el resto del contrato enTSC.md.
19. Variants son canon del eidos, NO del theme
Posición arquitectónica firmemente sostenida: el vocabulario de
variants (solid, outline, ghost, soft, surface, line,
pills, etc.) está fijo en el sistema. Un theme NO puede:
- Añadir un variant nuevo (no hay un
branded,bubble,corporateque cada theme invente). - Redefinir el cascade visual de un variant existente (
outlinesignifica "bordered restraint" en todos los themes — solo cambia el COLOR del border, no su geometría).
Las 3 capas de la cebolla
| Capa | Qué es | Quién la cambia |
|---|---|---|
| Sema families / intents | vocabulario perceptual canon del libro (8 families × 6 intents) | NADIE — fijo |
| Eidos variants | archetypes visuales perceptuales (5 archetypes shared + variants component-specific) | NADIE — fijo |
| Eidos color roles | los 9 roles canon (primary/affirm/risk/…) |
NADIE — fijo |
| Eidos color palette | qué hex es cada rol | THEME |
| Tokens internos de recipe | --toggle-solid-bg etc. |
App (override puntual en EidosConfig.recipes) |
Theming = retintar lo perceptualmente fijo. El theme cambia QUÉ
color es affirm, no QUÉ significa outline.
Por qué fijos
-
Portabilidad de componentes.
<Toggle variant="outline">debe renderizar coherentemente en cualquier theme. Theme-defined variants romperían eso silenciosamente — un componente que asumeoutlineno funcionaría en un theme que no lo declara. -
Type safety = parte del contrato. Los consumers necesitan
SelectionVariant = 'solid' | 'outline' | 'ghost'para autocomplete y TS errors. UnRecord<string, …>extensible perdería esa garantía. Radix Themes 3.x, Chakra v3, Mantine — todas las referencias serias mantienen variants fijos por componente. -
Variants son archetypes perceptuales, paralelos a sema families.
solid= "filled emphasis",outline= "bordered restraint",ghost= "ambient transparency",soft= "tinted background". Eso es vocabulario perceptual del framework — no decisión de theme. -
Hay 5 archetypes, no infinitos. El catálogo se cierra; el TS los rechaza si no son canónicos. Si emerge un nuevo archetype genuinamente perceptual, se añade a
EIDOS_VARIANTS— pero al nivel del framework, no al del theme.
Single source of truth — EIDOS_VARIANTS
src/uix/eidos/lib/types.ts declara la constante:
export const EIDOS_VARIANTS = {
control: ['surface', 'outline', 'ghost'],
selection: ['solid', 'outline', 'ghost'],
chip: ['soft', 'solid', 'outline', 'ghost'],
marker: ['solid', 'soft', 'outline'],
tabs: ['line', 'surface', 'pills']
} as const satisfies Readonly<Record<string, readonly string[]>>;
export type ControlVariant = (typeof EIDOS_VARIANTS.control)[number];
export type SelectionVariant = (typeof EIDOS_VARIANTS.selection)[number];
// …
Los 5 unions se DERIVAN de la const — el valor y el tipo no pueden desincronizarse. Cada componente narrow al archetype apropiado:
// components/toggle/types.ts
export type ToggleVariant = SelectionVariant;
// components/accordion/types.ts
export type AccordionVariant = ControlVariant;
Para narrowing más fino dentro de un archetype:
export type AlertDialogCancelVariant = Extract<ButtonVariant, ControlVariant>;
Variants component-specific
Algunos componentes tienen vocabularios genuinamente únicos:
Banner:inline | overlay | persistent(posicionamiento, no treatment perceptual).Spinner:bars | dots | ring(geometría del indicador).Button: añade'plain'para inline/link-like — no merece archetype propio porque solo aparece en Button + Code.
Estos viven en cada components/{c}/types.ts. El lint
recipe-css-contract.test.ts > variant CSS selectors per component match the declared type union valida bidireccionalmente:
- CSS usa
[data-{c}][data-variant='X']→ X debe estar en el union. - Type union declara
'X'→ CSS debería tener entries (advisory).
Lo que un theme SÍ puede
- Cambiar paletas (
ThemeColorSet.scales,ThemeColorSet.roles). - Cambiar shadows (
ShadowScale). - Cambiar typography styles (
TypographyPrimitiveSet.styles).
Lo que un theme NO puede
- Añadir variants. (
ThemeDefinitionno exponerecipes.) - Redefinir cascades visuales. (Los recipes son
EidosConfig.recipes, parte del bootstrap del app, no del theme.) - Cambiar roles de color. (Los 9 roles son canon.)
- Cambiar sizes canon. (
SIZE_PRIMITIVE_KEYSes fijo.)
Lo que el app SÍ puede (al boot, no per-theme)
- Override de tokens en
EidosConfig.recipes— cambia el RESULTADO del cascade visual, no añade un variant nuevo. - Crear componentes wrappers propios que compongan los primitives de eidos con className/style custom.
- Cambiar tokens en runtime via
ActiveEidos.setCssVariables()/clearCssVariables()(per-app variables CSS).
Cuándo añadir un archetype nuevo
Solo si EMERGE un patrón perceptual repetido en ≥3 componentes que no encaja en los 5 archetypes existentes. Procedimiento:
- Documentar el archetype con 1 párrafo describiendo el affordance perceptual (paralelo a "solid = filled emphasis").
- Añadirlo a
EIDOS_VARIANTSenlib/types.ts. - Export el type derivado.
- Migrar los componentes consumidores a referenciarlo.
- Actualizar esta sección.
Comparación con referentes
| Lib | Variants extensibles por theme | Variants extensibles por app |
|---|---|---|
| Radix Themes 3.x | ❌ | ❌ (fijos por componente) |
| Mantine 7+ | ❌ | ❌ (defaultProps + styles override) |
| Chakra UI v3 (Panda) | ❌ | ⚠️ via recipes config (compound variants) |
| Ark UI | n/a (100% headless, sin opinión) | n/a |
| shadcn/ui | n/a (copy-paste, no framework) | ✓ (copia + edita) |
| activeUIX | ❌ | ⚠️ via EidosConfig.recipes override (cambia tokens, no añade variants) |
activeUIX se alinea con Radix Themes y Mantine: framework con contrato fijo, theme con flexibilidad acotada al color/spacing. La extensibilidad extrema (Tailwind, CSS-in-JS plain) es deliberadamente NO el goal — porque la promesa del framework es portabilidad perceptual entre apps y themes.
§20–§38 — crónica movida a
THEMING_CHANGELOG.md. Estas secciones eran registros fechados de sprint (correcciones, incidentes, commits) mezclados con doctrina. La crónica completa vive ahora en el changelog con la misma numeración §N; abajo queda, por sección, la decisión vigente en una frase + el puntero a la fuente viva (RFC / config / generador). Las citas históricas§Ndel corpus y del código siguen resolviendo aquí.
20. Correcciones del engine de theming (2026-06-01)
Crónica en THEMING_CHANGELOG.md §20. Vigente: la
densidad emite escalares reales por nivel (data-density mueve
--density-space-scale / --density-control-scale) y el slot contrast
resuelve legible sobre sólidos (APCA on-solid con flip; ver §25 y
lib/render-css.ts).
21. Modelo de color de dos niveles (RFC — RESUELTO en §25)
RFC resuelto — crónica en THEMING_CHANGELOG.md §21.
El modelo vigente es el de §25 + COLOR_MODEL_RFC.md.
22. Mejoras pendientes del theming
Backlog histórico, resuelto — crónica en
THEMING_CHANGELOG.md §22. La auditoría priorizada
que lo absorbió es THEMING_AUDIT_2026-06-01.md
(scorecard completo).
23. Eje de scaling (zoom global) — 2026-06-02
Crónica en THEMING_CHANGELOG.md §23. Vigente:
data-scaling (90–110) es el zoom global — multiplica space,
control-height, font-size, icon-size (SÍ tipografía); NO escala radius /
border / sombra. Ortogonal a la densidad (que NO toca tipografía) y se
multiplica con ella. Diseño completo: SCALING_RFC.md.
24. Correcciones P2 del engine (2026-06-02)
Crónica en THEMING_CHANGELOG.md §24. Vigente: los
tintes surface/soft por rol son translúcidos por construcción
(--color-{role}-surface = a2, -surface-hover = a3) — componen sobre
fondos no uniformes.
25. Modelo de color — paleta + roles/intents derivados (2026-06-02)
Crónica en THEMING_CHANGELOG.md §25. Vigente (el
modelo de color canónico, tres capas):
- Paleta — escalas funcionales de 12 pasos + 12 alpha, diseñables por el
tema (
lib/themes/color-scales.ts+base.ts); identidad de una escala = step 9 (el sólido). Directamente usable:var(--scale-{name}-{step}). - Roles de jerarquía (
primary/secondary/tertiary) — alias explícito a una escala:THEME_BASE_COLOR_ROLES(lib/themes/base.ts), ver §4. - Intents — auto-derivados de la paleta por convención del libro
(
CANONICAL_INTENT_SCALES,lib/config-types.ts); el tema puede desviarse (convención ≠ autoría, §4).
Los slots por rol son COLOR_ROLE_SLOTS (§3, capa 3 — única fuente). Detalle
y racional: COLOR_MODEL_RFC.md +
COLOR_ENGINE_RFC.md.
26. Theme builder en runtime — eidos.applyColorScheme (2026-06-04)
Crónica en THEMING_CHANGELOG.md §26. Vigente:
buildScheme(seed, opts) (puro, lib/build-scheme.ts) +
ActiveEidos.applyColorScheme(seed, opts) / clearColorScheme() — deriva un
scheme completo (roles + alphas a1..a12 + on-solid APCA) del tema activo y
re-deriva al cambiar de modo. Capas: uix.color = matemática ·
build-scheme = composición pura · ActiveEidos = aplicación DOM.
API: COLOR_ENGINE_RFC.md §6.2/§7.
27. Salida wide-gamut OKLCH (default-on) (2026-06-04)
Crónica en THEMING_CHANGELOG.md §27. Vigente: cada
paso de paleta emite hex (fallback) + hermano oklch() que gana donde se
soporta — default-on, sin flag (appendColorScaleDeclarations,
lib/render-css.ts). El wide-gamut REAL vive en el generador (buildScheme
retiene el OKLCH sin clamp → result.wideGamut).
28. Accesibilidad forced-colors + ramp de bordes (2026-06-05)
Crónica en THEMING_CHANGELOG.md §28. Vigente: el
foundation emite siempre @media (forced-colors: active) (focus por outline
— el box-shadow muere en HCM) y @media (prefers-contrast: more) (bordes y
texto reforzados vía :root:root); el slot border = step 7 de la escala
(DEFAULT_COLOR_ROLE_SLOT_STEPS).
29. Profundidad (depth) — canal unificado + eventful (2026-06-05)
Crónica en THEMING_CHANGELOG.md §29. Vigente: la
profundidad es UN canal — data-depth='{plane}' (flush · raised · overlay · modal · recessed) cohere superficie + sombra + halo + z en reposo, y la firma
de evento (present-rise / press-squeeze) la mueve en el momento-evento.
Planos config-driven (EidosConfig.depth.planes); los overlays componen
var(--depth-{plane}-shadow), var(--depth-{plane}-halo). Guía canónica:
DEPTH_ENGINE_RFC.md. Demo: /temas/profundidad.
30. Forma (shape) — continuidad + familias + anidado + eventful (2026-06-05)
Crónica en THEMING_CHANGELOG.md §30. Vigente: la
forma es un canal — --shape-smoothing + data-shape='{family}'
(rounded · continuous · cut · scoop) sobre corner-shape, con degradación
al arco de border-radius; armonía anidada vía [data-shape-nest]
(concéntrico); el squircle es el default del tier surface
(renderShapeBlocks :where + --shape-surface-default; shape = opt-OUT).
Builder runtime applyShape(seed). Guía canónica:
SHAPE_ENGINE_RFC.md. Demo: /temas/forma.
31. Estructura (espacio · densidad · escala) — el espacio como ritmo (2026-06-05)
Crónica en THEMING_CHANGELOG.md §31. Vigente: los
tres ejes estructurales son solo-estado — densidad (data-density), scaling
(data-scaling, §23) y espacio: --space-{key} =
calc(value · var(--density-space-scale) · var(--scaling)), regenerable desde
una semilla modular/fluida (buildSpaceScale + applySpacing). Guía
canónica: STRUCTURE_ENGINE_RFC.md. Demo:
/temas/estructura.
32. Focus ring — modelo de dos anillos parametrizado (2026-06-11)
Crónica en THEMING_CHANGELOG.md §32. Vigente: UN
modelo de foco — el anillo canónico (--focus-ring + los *-focus-shadow de
recipes) es dos anillos parametrizados (--focus-ring-inner-width, default
0 = solo marco exterior; STATIC_FOCUS_RING, primitives/static.ts); el
anillo del foundation excluye los elementos internos de campo. Nota a11y §28:
en forced-colors el foco visible es outline (la migración per-componente de
box-shadow → outline es trabajo abierto).
33. Glifos de stepper themeables (spin-field) — 2026-06-11
Crónica en THEMING_CHANGELOG.md §33. Vigente: los
glifos del stepper (spin-field) son tokens de recipe themeables, no SVG
hardcodeado.
34. spin-field — visual compartido del stepper-field (number-field / css-field) — 2026-06-11
Crónica en THEMING_CHANGELOG.md §34. Vigente:
number-field y css-field comparten UNA capa visual vía identidad
estructural data-spin-field* (components/spin-field/spin-field.css,
agregada en index.css) — sin clon.
35. Canon de escalas — auditoría de theming (2026-06-15)
Crónica en THEMING_CHANGELOG.md §35. Vigente: cada
eje de escala (blur · inner-shadow · inset-ring · gradientes · breakpoints ·
container · opacidad — dual numérica+semántica · border-width · tracking) es
retunable por tema y los recipes consumen el token, nunca un literal
(guards R-2.x/R-4.x). El inventario vive en EidosConfig
(lib/primitives/* + config-types.ts) y la tabla de naming en §6;
breakpoints con fuente en ActiveDom. Los 3 arquetipos de size
(control · compact · dense) están en §5.
36. Gap canónico trigger→panel — offset token-driven (2026-06-22)
Crónica en THEMING_CHANGELOG.md §36. Vigente: el
gap trigger→panel es un OFFSET del motor de posicionamiento vía
@property --floating-gap — menús 0, paneles --space-1-5.
37. Touch-target — 44px en táctil, gated por puntero (2026-06-28)
Crónica en THEMING_CHANGELOG.md §37. Vigente: los
touch-targets de 44px se aplican SOLO bajo @media (pointer: coarse);
markers vía label-row, sin reestructurar DOM.
38. Capa de estado (state-layer) — feedback neutro unificado (2026-06-28)
Crónica en THEMING_CHANGELOG.md §38. Vigente: el
feedback interactivo neutro (hover/press/selected) es la capa de estado MD3 —
tokens --state-hover / --state-press / --state-selected compuestos como
background-image: linear-gradient(...) en archetypes.css; los hovers
bespoke por componente están deprecados (R-4.3).
Última revisión: 2026-07-02. Si algo en este doc no coincide con el código, el código gana — pero abre un issue para que actualicemos el doc.