You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/THEMING.md

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:purge para 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

  1. Mental model 1bis. Theming vive en Eidos, no en Morfo (por diseño)
  2. Las capas del CSS de Eidos
  3. Las 7 capas de tokens
  4. Los 9 roles canónicos de color
  5. El canon de sizes
  6. Convenciones de naming
  7. Token Scope Contract (TSC)
  8. Cómo añadir un componente nuevo
  9. Cómo definir un theme
  10. Cómo overridear tokens en runtime
  11. Bundle strategy + eidos:purge
  12. Herramientas de validación
  13. Integración con Sema (event:* scope)
  14. Motion
  15. Comparación con librerías de referencia
  16. Anti-patterns que NO debes cometer
  17. FAQ — decisiones polémicas
  18. Cobertura universal de TSC
  19. Variants son canon del eidos, NO del theme
  20. Correcciones del engine de theming (2026-06-01)
  21. Modelo de color de dos niveles (RFC — RESUELTO en §25)
  22. Mejoras pendientes del theming
  23. Eje de scaling (zoom global)
  24. Correcciones P2 del engine (2026-06-02)
  25. Modelo de color — paleta + roles/intents derivados
  26. Theme builder en runtime — eidos.applyColorScheme
  27. Salida wide-gamut OKLCH (default-on)
  28. Accesibilidad forced-colors + ramp de bordes
  29. Profundidad (depth) — canal unificado + eventful
  30. Forma (shape) — continuidad + familias + anidado
  31. Estructura (espacio · densidad · escala)
  32. Focus ring — modelo de dos anillos parametrizado
  33. Glifos de stepper themeables (spin-field)
  34. spin-field — visual compartido del stepper-field
  35. Canon de escalas — auditoría de theming (2026-06-15)
  36. Gap canónico trigger→panel — offset token-driven (2026-06-22)
  37. Touch-target — 44px en táctil, gated por puntero (2026-06-28)
  38. 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 ActiveEidos que 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

  1. "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.
  2. "Vamos a hacer que TSC valide color:affirm importando toggleMorfo.data['data-color'].values" — viola regla #6 (eidos no importa internals de morfo en TS).
  3. "Vamos a meter variant: 'solid' | 'outline' en el morfo del Toggle" — viola 2-de-3 (variants solo las consume eidos).
  4. "Vamos a definir size en 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 color como prop → debe declarar data-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) → declara data-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 .svelte importa su propio CSS y Vite emite un chunk por componente. La AUSENCIA de un @import es 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 @import estático. themes/fonts.css quedó superseded — los @font-face viven en EidosConfig y salen en generated/base.css.

Por qué este orden importa

  • generated/base.css declara tokens (no estiliza). Si los recipes se cargan antes, no tienen los tokens disponibles.
  • archetypes.css setea baseline interactiva (cursor, hover, focus ring). Recipes específicos sobrescriben.
  • events.css reacciona a data-event-* con animation: @keyframes (no transition) 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, edita lib/recipes/base.ts o lib/themes/base.ts y corre npm 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. Usa animation: @keyframes, NO transition. Lee data-event-intent (signal-bound), NUNCA data-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-color prop y necesita un palette dinámico. TSC scope: 'host' + overrides scope: 'color:X'.
  • Capa 6 (variant): cuando una variant (solid, outline, etc.) combina palette + algo específico. TSC scope: '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/tertiary son jerárquicos: usar cuando la diferencia es "más vs menos prominente". Sin carga evaluativa.
  • neutral es el default. Sin carga semántica.
  • affirm/fulfill/risk/threat/loss son evaluativos: comunican qué pasa con la acción.
  • Si intent === 'neutral', color (hierarchy override) puede aplicar. Si intent es evaluativo, el intent GANA y color se 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

md es 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 "dos md" (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/dense de 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-md a 15px re-adapta el tema entero sin tocar un solo recipe. El md-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, --scaling y applyTypeScale()).

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 referencia var(--icon-size-{size}), paralelo al font 1:1. La escala de iconos preserva icon ≈ font+2 en cuerpo (md: font 16 → icono 18). Aplicado a button + search-field. Excepciones: password-field — su icon-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

  1. Todos los public tokens del Eidos llevan el prefijo -- sin sub-prefijo de capa. Razón: clarity en debug. Ver --toggle-bg y sabes que es Eidos. Ver --bg y no sabes de dónde viene.
  2. NUNCA usar --eidos- como prefijo. La capa ya está implícita en el path $uix/eidos/components/{c}.
  3. NUNCA usar --soma- ni --air- ni --terra-. Esas capas son muertas o no poseen tokens.
  4. Los component tokens siguen el patrón --{component-kebab}-.... El componente kebab es el nombre del directorio.
  5. No abreviar nombres de componente. dropdown-menu no se vuelve ddmenu. La authorship clarity vale 6 chars.
  6. NO incluir el segmento "color-" intermedio en tokens de color. --toggle-affirm-solid (correcto), --toggle-color-affirm-solid (deprecado 2026-05-27).
  7. 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.ts para primitives, scales, theme variants.
  • lib/recipes/base.ts para 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 de scopeCovers, 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 de component-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:

  1. Valida cada nombre contra getCssContract(). Tokens fuera del contrato lanzan error en modo strict (default).
  2. Renderiza transacionalmente: primero render + valida, luego reemplaza el <style> block runtime.
  3. 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

  1. 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).
  2. Source-scan tokens: cada var(--XXX) y --XXX: declaración encontrada en source → XXX pinned.
  3. Component-import detection: cada from '...components/{c}' → recipe completo de {c} pinned.
  4. Data-attr detection: cada data-{c}= (filtrado contra registry canonical de recipes) → recipe completo de {c} pinned.
  5. 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 construyes var(--${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 en motion.signatures y se genera como CSS contra data-event-* directamente — sin el scope TSC event:* ni el data-motion-ref que esta sección discutía. El motor (EngineMotion) es un servicio en arts/motion (uix.motion). El cuerpo original (contexto histórico de la decisión) vive en THEMING_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 "TSC event:* scope" (§13) como su futuro. Eso quedó obsoleto: la firma perceptiva migró al registro de signatures (no a events.css) y el motor es hoy un servicio en arts/motion. eidos-motion.md es 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 (no transform) para componer con la posición, que viaja por translate (propiedades distintas) → el lift no pelea con el arrastre 1:1.
  • El cambio de scale se transiciona (lift al agarrar / settle al soltar); durante el move queda estático (compuesto, sin coste por frame).
  • will-change: translate, scale mientras 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


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 en TSC.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, corporate que cada theme invente).
  • Redefinir el cascade visual de un variant existente (outline significa "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

  1. Portabilidad de componentes. <Toggle variant="outline"> debe renderizar coherentemente en cualquier theme. Theme-defined variants romperían eso silenciosamente — un componente que asume outline no funcionaría en un theme que no lo declara.

  2. Type safety = parte del contrato. Los consumers necesitan SelectionVariant = 'solid' | 'outline' | 'ghost' para autocomplete y TS errors. Un Record<string, …> extensible perdería esa garantía. Radix Themes 3.x, Chakra v3, Mantine — todas las referencias serias mantienen variants fijos por componente.

  3. 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.

  4. 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. (ThemeDefinition no expone recipes.)
  • 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_KEYS es 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:

  1. Documentar el archetype con 1 párrafo describiendo el affordance perceptual (paralelo a "solid = filled emphasis").
  2. Añadirlo a EIDOS_VARIANTS en lib/types.ts.
  3. Export el type derivado.
  4. Migrar los componentes consumidores a referenciarlo.
  5. 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 §N del 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.

Powered by TurnKey Linux.