96 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 31 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 6 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 (estado actual)
- 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
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. - 5 layers de CSS (archetypes, events, foundation generado, recipes, themes).
- 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 6 capas del CSS de Eidos
src/uix/eidos/index.css es el entrypoint. Importa, en orden:
1. themes/fonts.css ← font faces
2. generated/base.css ← foundation + recipes tokens (226 KB)
3. archetypes.css ← reglas transversales por data-archetype
4. events.css ← reacciones a data-event-* (sema)
5. lib/menu-indicator.css ← partial compartido
6. components/{c}/{c}.css × ~95 ← recipes per-component
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 31 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). Hoy hay 9 slots (track, element, hover, active, border, solid, solid-hover, text, contrast). - 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):
| Role | Escala Radix | Reasoning |
|---|---|---|
| primary | indigo | Brand default, neutral-positive |
| secondary | slate | Support hierarchy, slate-blue |
| tertiary | (lo elige el theme) | — |
| neutral | gray | Sin carga |
| affirm | teal | Light positive, mint-fresh |
| fulfill | green | Completion, classic success |
| risk | amber | Caution, warning |
| threat | red | Active danger |
| loss | plum | Posterior gravity, deep |
Los intents auto-derivan de la paleta (
CANONICAL_INTENT_SCALES, identidad = step 9):neutral→gray · affirm→teal · fulfill→green · risk→amber · threat→red · loss→plum. La jerarquía (primary/secondary/tertiary) la elige el tema. Modelo completo en §25.
La paleta son 31 escalas de 12 steps + 12 alpha = 24 tokens c/u
(744 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: 14px
--size-md-font-line-height: 1.5
--size-md-icon-size: 16px
--size-md-padding-inline: 12px
--size-md-padding-block: 8px
--size-md-gap: 8px
--size-md-radius: 6px
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-size-md-control-height: 32px; }
}
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.
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 |
--shadow-{n} |
Shadow scale | --shadow-3 |
--z-index-{key} |
Z-index layer | --z-index-modal |
--opacity-{key} |
Opacity | --opacity-disabled |
--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-radius-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:
track, element, hover, active, border, solid, solid-hover, text, contrast(de capas 3-5),bg, fg, border, on-bg, on-fg, on-border, hover-bg, on-hover-bg(de capas 6-7).
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)
Eidos does not infer token scope from emitted CSS. Token scope is part of the source contract. The generator emits CSS from scoped declarations and validates that every token dependency is available in the consumer scope.
TSC es la pieza arquitectónica que distingue a Eidos de Tailwind / Radix / Chakra / Mantine / shadcn. Resuelve un problema sutil pero crítico que ningún otro sistema cierra estructuralmente.
El problema que resuelve
CSS custom property substitution es eager, no lazy:
:root {
--base: black;
--derived: var(--base);
}
.x { --base: red; }
.y { background: var(--derived); }
¿Qué color tiene .x.y? NEGRO, no rojo. --derived se computa
en :root con --base=black y se hereda como black. El override
de .x sobre --base no afecta a --derived ya congelado.
Aplicado al Toggle pre-TSC:
:root {
--toggle-palette-solid: var(--toggle-color-neutral-solid);
--toggle-solid-on-bg: var(--toggle-palette-solid); /* CONGELADO */
}
[data-toggle][data-color='affirm'] {
--toggle-palette-solid: var(--toggle-color-affirm-solid); /* INÚTIL */
}
--toggle-solid-on-bg quedaba congelado al neutral. El toggle con
data-color='affirm' mostraba gris en vez de teal. Bug
arquitectónico que ningún linter detectaría.
Cómo TSC lo cierra
El config del recipe declara dónde se emite cada token:
recipes.toggle = {
'palette-solid': {
declarations: [
{ value: 'var(--toggle-color-neutral-solid)', scope: 'host' },
{ value: 'var(--toggle-color-affirm-solid)', scope: 'color:affirm' },
{ value: 'var(--toggle-color-threat-solid)', scope: 'color:threat' }
]
},
'solid-on-bg': {
value: 'var(--toggle-palette-solid)',
scope: 'host' // ← obligatorio: dep está en 'host', no en 'root'
}
}
El generador:
- Infiere
dependsparseandovar(--{c}-XXX)del value. - Valida transitivamente:
solid-on-bg(scopehost) depende depalette-solid(scopehosto más específico) — OK. - Emite cada declaración bajo su selector:
host→[data-toggle],color:affirm→[data-toggle][data-color='affirm'], etc. - Falla el build si el scope del consumer no cubre el del dep.
Scopes disponibles
| Scope | Selector generado | Cuándo usar |
|---|---|---|
'root' |
:root |
Token estable. Default para bare-string. |
'host' |
[data-{c}] |
Token referencia var(--{c}-palette-*) u otro host token. |
color:${v} |
[data-{c}][data-color='${v}'] |
Override del palette por color value. |
variant:${v} |
[data-{c}][data-variant='${v}'] |
Cascada de variante. |
state:${v} |
[data-{c}][data-state='${v}'] |
Cascada de estado. |
size:${v} |
[data-{c}][data-size='${v}'] |
Cascada de tamaño. |
event:${v} |
[data-{c}][data-event='${v}'] |
Token de motion ligado a señal perceptual. |
[axis:v, …] |
[data-{c}][data-X='v'][data-Y='w'] |
Composite — múltiples condiciones ANDed. |
Tres formas de declarar un token
recipes.toggle = {
// (1) Forma corta — scope 'root' implícito (token estable)
'height-md': '32px',
// (2) Forma simple — una declaración con scope explícito
// depends se infiere automáticamente de var() en el value
'solid-on-bg': {
value: 'var(--toggle-palette-solid)',
scope: 'host'
},
// (3) Forma multi-declaración — el MISMO token bajo distintos scopes
// (la realidad CSS de un custom property redeclarado por cascada)
'palette-solid': {
declarations: [
{ value: 'var(--toggle-color-neutral-solid)', scope: 'host' },
{ value: 'var(--toggle-color-affirm-solid)', scope: 'color:affirm' }
]
}
};
Álgebra de scope (scopeCovers)
No es un orden total simple. La regla es:
consumer scopeCovers dep⇔ todo elemento que matchea el consumer's scope también matchea el dep's scope.
Equivalentemente: las constraints del dep deben ser un subconjunto de las constraints del consumer.
| consumer | dep | covers? | Razón |
|---|---|---|---|
host |
root |
✓ | host es más específico, root siempre aplica |
host |
host |
✓ | mismo scope |
host |
color:affirm |
✗ | consumer no constraint el color |
color:affirm |
host |
✓ | host cubre todo el host scope |
color:affirm |
color:affirm |
✓ | mismo scope |
color:affirm |
color:loss |
✗ | scopes incompatibles (diferentes values del mismo axis) |
color:affirm |
size:lg |
✗ | consumer no constraint el size |
[color:affirm, size:lg] |
color:affirm |
✓ | composite cubre cada componente |
[color:affirm, size:lg] |
size:lg |
✓ | igual |
Cross-axis collision detection
Si un token tiene declaraciones en axes incomparables (e.g.
color:affirm y state:on), un elemento con ambos atributos matchea
ambos bloques. El cascade winner depende de orden de declaración —
silent correctness bug.
El generador detecta esto y exige una declaración composite que desambigüe:
'bg': {
declarations: [
{ value: 'red', scope: 'color:affirm' },
{ value: 'blue', scope: 'state:on' },
{ value: 'purple', scope: ['color:affirm', 'state:on'] } // ← obligatorio
]
}
Sin la composite, build falla:
Eidos recipe scope contract violations:
- synth.bg: declarations at scopes color:affirm and state:on can both
apply to the same element. Add an explicit composite declaration
[color:affirm, state:on] to disambiguate cascade order.
Multi-part scope — parts: [...] (TSC v2.2)
Cuando data-color (u otro axis TSC) NO vive en el root del componente
sino en parts específicos, el generador emite una regla con selector
comma-separado:
// recipes.select._accent-track
{
parts: ['trigger', 'content'],
declarations: [
{ value: 'var(--select-primary-track)', scope: 'host' },
{ value: 'var(--select-affirm-track)', scope: 'color:affirm' }
]
}
Genera:
[data-select-trigger], [data-select-content] {
--_select-accent-track: var(--select-primary-track);
}
[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm'] {
--_select-accent-track: var(--select-affirm-track);
}
Cuándo usarlo: el componente porta data-color per-part (típicamente
porque un part viaja por portal y se renderiza fuera del árbol DOM del
otro). Single-part components siguen sin necesitar parts — el default
[data-{c}] es lo correcto.
Quién lo usa hoy: select (trigger + content) — único caso real
en el catálogo. Los demás componentes con data-color lo declaran en
el root.
Cross-recipe composition — composition: { ... } (TSC v2.2)
Cuando un recipe necesita modificar tokens de OTRO recipe scoped a su
propio cascade, declara un bloque composition sibling de los tokens
regulares:
// recipes.toggle-group
{
gap: 'var(--space-1)',
composition: {
toggle: { // foreign recipe name
targetSelector: '[data-toggle-group-item]', // descendant selector
tokens: {
'palette-solid': {
declarations: [
{ value: 'var(--toggle-affirm-solid)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-solid)', scope: 'color:risk' }
]
}
}
}
}
}
Genera:
[data-toggle-group][data-color='affirm'] [data-toggle-group-item] {
--toggle-palette-solid: var(--toggle-affirm-solid);
}
[data-toggle-group][data-color='risk'] [data-toggle-group-item] {
--toggle-palette-solid: var(--toggle-risk-solid);
}
Reglas:
- El CSS variable name se deriva del recipe FORÁNEO
(
--toggle-palette-solid), no del host. Para tokens privados del foreign use_palette-solid→--_toggle-palette-solid. - El selector es
{host's scope-rule} {targetSelector}— combinación ancestor + descendant. - Las composition declarations DEBEN tener scope ≠
'root'. Un override no-scoped pertenece al foreign recipe, no al composition block. El validador rechaza root-scoped composition entries. - Composition NO se valida con el algebra de scope del host (las
composition entries modifican TOKENS del foreign, no del host), pero
sí pasa por el mismo pipeline de validación general
(
validateRecipeComposition).
Quién lo usa hoy: toggle-group (modifica --toggle-palette-* en
sus items). Pattern reutilizable para futuros wrappers compositivos
(button-group, nav-menu).
Pipeline de defensas (5 capas)
1. tsc --noEmit ← TS bien tipado
2. TSC scope algebra ← ningún token depende de scope más dinámico
3. TSC cross-axis check ← composites obligatorios donde hay collision
4. eidos-lint ← defensa secundaria del CSS generado
5. runtime probe ← confirma comportamiento real en browser
8. Cómo añadir un componente nuevo
Asumiendo que ya tienes el morfo, soma y el scaffolding del wrapper
eidos (src/uix/eidos/components/{name}/):
Paso 1 — Decide qué tokens necesitas
Mira componentes similares (button, toggle, switch). Identifica
qué dimensiones tu componente expone:
- ¿Tiene
data-color? → palette tokens. - ¿Tiene
data-variant? → variant tokens. - ¿Tiene
data-size? → size tokens. - ¿Cuántas partes tiene? → tokens por part.
Paso 2 — Añade el recipe en lib/recipes/base.ts
// Within THEME_BASE_RECIPE_TOKENS:
'my-component': {
// size tokens — scope 'root' (estables)
'height-md': '36px',
'padding-inline-md': 'var(--space-3)',
'gap': 'var(--space-2)',
'radius': 'var(--radius-md)',
// per-color literal definitions — scope 'root'
'primary-solid': 'var(--color-primary-solid)',
'affirm-solid': 'var(--color-affirm-solid)',
'threat-solid': 'var(--color-threat-solid)',
// palette dinámica — scope 'host' default + overrides por color
'palette-solid': {
declarations: [
{ value: 'var(--my-component-primary-solid)', scope: 'host' },
{ value: 'var(--my-component-affirm-solid)', scope: 'color:affirm' },
{ value: 'var(--my-component-threat-solid)', scope: 'color:threat' }
]
},
// tokens derivados — scope 'host' (deps inferidas)
'solid-bg': {
value: 'var(--my-component-palette-solid)',
scope: 'host'
}
}
Paso 3 — Regenera
npm run generate:eidos-css
Si tu config viola TSC, te avisa al regenerar:
Eidos recipe scope contract violations:
- my-component.solid-bg (scope root): dependency 'palette-solid' is only
declared at scopes [host], none of which is reachable from the
consumer's scope.
Paso 4 — Escribe el recipe CSS
src/uix/eidos/components/my-component/my-component.css:
[data-my-component] {
/* Private tokens — sólo este recipe los lee */
--_my-component-bg: var(--my-component-solid-bg);
--_my-component-radius: var(--my-component-radius);
display: inline-flex;
align-items: center;
padding-inline: var(--my-component-padding-inline-md);
height: var(--my-component-height-md);
border-radius: var(--_my-component-radius);
background: var(--_my-component-bg);
gap: var(--my-component-gap);
}
[data-my-component][data-disabled] {
opacity: var(--opacity-disabled);
pointer-events: none;
}
Paso 5 — Importa en index.css
@import './components/my-component/my-component.css';
Paso 6 — Verifica
npm run generate:eidos-css
npm test -- src/uix/eidos
npm run morfo:check
node --import tsx/esm scripts/eidos-lint.ts my-component
Anti-pattern común: declarar tokens compuestos en :root
// ❌ INCORRECTO — TSC fallará
'my-component': {
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
'solid-bg': 'var(--my-component-palette-solid)' // ← scope 'root' implícito, deps en 'host'
}
// ✓ CORRECTO
'my-component': {
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
'solid-bg': {
value: 'var(--my-component-palette-solid)',
scope: 'host'
}
}
9. Cómo definir un theme
Eidos soporta 3 modos de definir un theme:
Modo 1 — Patch del theme base (recomendado)
Cambia sólo lo que necesitas; el resto sigue el base:
import { ActiveEidos } from '$uix/eidos';
ActiveEidos.create({
themeBase: {
semantics: {
color: {
roles: {
primary: 'blue', // primary usa la escala blue Radix
secondary: 'plum'
}
}
},
primitives: {
typography: {
families: {
primary: { family: 'Inter' }
}
}
}
},
applyDom: true
});
Modo 2 — Config completa
Si quieres autoría desde cero:
import { ActiveEidos, defineEidosConfig } from '$uix/eidos';
const config = defineEidosConfig({
primitives: { /* … */ },
semantics: { color: { /* … */ } },
themes: { /* … */ }
});
ActiveEidos.create({ config, applyDom: true });
Modo 3 — Theme CSS-only (sin TypeScript)
Eidos publica el contrato como CSS vacío para que externals lo sobrescriban:
const contract = activeEidos.renderContractCss({
themeSelector: "[data-theme='acme-light']"
});
// Output:
// [data-theme='acme-light'] {
// --scale-blue-9: ;
// --color-primary-solid: ;
// --size-md-control-height: ;
// ...
// }
El consumer rellena los valores:
[data-theme='acme-light'] {
--scale-blue-9: #006adc;
--color-primary-solid: var(--scale-blue-9);
--size-md-control-height: 38px;
}
Y carga ese CSS junto con el de Eidos. Con themeSource: 'css',
ActiveEidos no genera theme propio.
Persistencia versionada
const document = activeEidos.toDocument();
// → { kind: 'uix.eidos-config', version: 1, options: {...} }
const json = activeEidos.serialize();
localStorage.setItem('user-theme', json);
// Más tarde
const hydrated = createActiveEidos({
config: JSON.parse(localStorage.getItem('user-theme')!),
prefs, dom
});
El document envelope tiene version para migraciones futuras.
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 31 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 (§13 + §14). 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 estas secciones discuten. El motor (EngineMotion) es un servicio enarts/motion(uix.motion). Se conservan como contexto histórico de la decisión; no son la API actual.
Sema emite data-event-* durante hold windows perceptuales. Eidos
reacciona vía events.css (animations) o vía tokens scoped a event:*.
Tokens scoped a event:*
Permite que un token cambie SU VALOR durante una señal:
recipes.toast = {
// Color base — scope 'host'
'bg': {
value: 'var(--color-surface-raised)',
scope: 'host'
},
// Override durante señal de announce — el toast cambia su bg
// mientras dura la señal perceptual
'bg-during-announce': {
value: 'var(--color-primary-element)',
scope: 'event:announce'
}
};
CSS generado:
[data-toast] {
--toast-bg: var(--color-surface-raised);
}
[data-toast][data-event='announce'] {
--toast-bg-during-announce: var(--color-primary-element);
}
El recipe usa el token apropiado:
[data-toast] {
background: var(--toast-bg);
}
[data-toast][data-event='announce'] {
background: var(--toast-bg-during-announce);
}
Por qué NO usar data-motion-ref
eidos-motion.md propuso un atributo nuevo data-motion-ref y un
registry separado. TSC absorbe esa necesidad sin nueva superficie
DOM: el scope event:* se materializa contra data-event='X' que
sema ya emite.
Reduced motion
Eidos lee data-motion (la pref global proyectada por ActivePrefs):
[data-motion='reduce'] [data-event][data-event-phase='active'] {
animation-duration: 1ms;
transition-duration: 1ms;
}
Cobertura per-event vive en events.css. Cobertura per-token
(durante señal) puede vivir como composite scope [event:X, motion:reduce]
si necesitas afinar.
14. Motion (estado actual)
Lo que YA tienes
| Locación | Cobertura |
|---|---|
archetypes.css |
4 transitions baseline (trigger, indicator, thumb, close) |
events.css |
9 keyframes + reactions a data-event-* con intent tinting |
components/{c}/{c}.css |
transitions/animations específicas del componente |
Total: ~30 keyframes únicos distribuidos en el árbol + transitions inline en cada recipe.
Lo que ESTÁ deferred (eidos-motion.md)
Documento de propuesta sin implementar. Define:
- Atributo
data-motion-ref(NO existe) - Registry tipado
EidosConfig.motion(NO existe) - Drivers
css / eidos-rect / waapi
Estado: superseded por TSC scope event:* para el caso de tokens
ligados a señales. Los drivers eidos-rect (medición de rects) y
waapi (keyframes runtime) siguen diferidos hasta que aparezca un
consumer real (e.g. presence.genie fly-to-target).
Cuándo añadir un keyframe nuevo
Añade a events.css si:
- Reacciona a una señal perceptual concreta (
data-eventodata-event-family). - Es transversal (varios componentes pueden compartirlo).
Añade al recipe del componente si:
- Es específico (un slider drag, una calendar swap).
- No reacciona a una señal de sema, sino a un
data-statetransition.
15. Comparación con librerías de referencia
Bundle size (app típica con 10 componentes)
| Sistema | Raw | Gzip | Strategy |
|---|---|---|---|
| Tailwind v4 | ~30 KB | ~10 KB | JIT atomic classes |
| Eidos + purge | 116 KB | 13.6 KB | JIT custom-property purge |
| Chakra Panda v3 | ~30 KB | ~12 KB | Build-time JIT |
| Mantine | ~60 KB | ~18 KB | Sin purge |
| Radix Themes | ~80 KB | ~22 KB | Sin purge |
| shadcn/ui | varía | varía | Copy-paste, no centralized |
| Eidos sin purge | 218 KB | 25 KB | Single CSS |
Theming features
| Feature | Eidos | Radix Themes | Chakra Panda | Mantine | shadcn | Tailwind v4 |
|---|---|---|---|---|---|---|
| Token scope as data | ✅ TSC | ❌ implícito | ⚠️ build-time | ❌ runtime | ❌ N/A | ❌ N/A |
| Auto-inferencia de deps | ✅ var() parse |
❌ | ✅ types | ❌ | N/A | N/A |
| Cross-axis collision | ✅ explícito | ❌ | ⚠️ partial | ❌ | N/A | N/A |
| Composite scopes | ✅ [axis:v, …] |
❌ | ✅ conditional pairs | ❌ | N/A | N/A |
| 9 color roles canónicos | ✅ libro | ⚠️ 6 accents | ❌ open | ❌ open | ⚠️ 4 roles | ❌ open |
| 6 size canon coordinated | ✅ | ⚠️ 1-3 | ⚠️ 5 | ⚠️ 5 | ❌ | N/A |
| Density runtime | ✅ 3 levels | ❌ | ❌ | ⚠️ partial | ❌ | ❌ |
| Contract introspection | ✅ typed | ⚠️ docs | ✅ Panda | ⚠️ docs | ❌ | ❌ |
| Runtime override | ✅ contract-aware | ⚠️ via CSS vars | ❌ | ✅ CSSVarsProvider | ⚠️ via CSS | ❌ |
| Persistence versioned | ✅ envelope | ❌ | ❌ | ❌ | ❌ | N/A |
| Perceptual layer (sema) | ✅ unique | ❌ | ❌ | ❌ | ❌ | ❌ |
Mental model
| Sistema | Token philosophy |
|---|---|
| Eidos | 7 layers de indirección, scope-as-contract, perceptual integration |
| Radix Themes | 3 layers, accent runtime swap, no scope contract |
| Chakra Panda | Conditional values build-time, recipe system |
| Mantine | Theme provider runtime, string interpolation |
| shadcn | Plano --primary + .dark, copy-paste components |
| Tailwind v4 | @theme directive, atomic utilities, no tokens compuestos |
Lectura crítica: Eidos NO es más simple que Tailwind ni más ergonómico que shadcn. Es más expresivo en la dimensión "qué puede comunicar un componente". Si tu app solo necesita un color primario y un dark mode, shadcn es la respuesta. Si tu app necesita diferenciar perceptualmente entre "guardar borrador" (affirm) y "borrar permanentemente" (loss) con tokens y animaciones distintas, Eidos es el sistema.
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 31 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
¿Por qué el theming no vive en Morfo? ¿No debería Morfo ser source of truth de todo?
Pregunta CRÍTICA — respuesta detallada en sección 1.bis.
Resumen: morfo es source-of-truth del contrato cross-layer (parts,
events, attrs, archetypes, attr values). Tokens/themes/recipes pertenecen
a Eidos por diseño explícito de la arquitectura (active_architecture.md
§9). La regla 2-de-3 lo deriva: los tokens visuales los consume solo
eidos → 1-de-3 → no entra en morfo. La integración eidos↔morfo se hace
a través del DOM (los recipes targetean attrs que morfo declara), NO
importando objetos morfo en TS (la regla #6 lo prohíbe explícitamente).
¿Por qué 7 capas de indirección? Parece excesivo
Cada capa sirve un override point real:
- Sin capa 1, no puedes traer una paleta Radix custom.
- Sin capa 2, no puedes remapear roles.
- Sin capa 3, no puedes ajustar slots per role.
- Sin capa 4, no puedes overridear un color SOLO para un componente.
- Sin capa 5, no puedes tener palette dinámico por instancia.
- Sin capa 6, no puedes combinar variant × palette.
- Sin capa 7, los recipes mezclan tokens externos con internos.
La capa 4 es la más sospechosa de redundancia. Es candidata a deprecate si después de 6 meses ningún consumer la usa.
¿Por qué TSC y no simplemente convención?
Convención falla en silencio. El bug del Toggle (pre-TSC) habría quedado escondido años. Con TSC, el build falla. Es la diferencia entre "deberías hacerlo bien" y "no puedes hacerlo mal".
¿Por qué no usar Tailwind si es más pequeño?
Tailwind:
- No tiene roles semánticos canónicos (success/danger/warning sí pero son del mundo Bootstrap).
- No tiene perceptual layer (sema).
- No tiene density runtime.
- No tiene contract introspection programática.
Pero si tu app es simple, úsalo. Eidos justifica su complejidad sólo cuando la app necesita las dimensiones que Eidos cubre.
¿Por qué no atomic classes como Tailwind?
Custom properties permiten:
- Cascada dinámica (palette overrides en runtime).
- Theme switching sin recompile.
- Persistencia del user's theme.
- Composability con sema (
event:*scope).
Atomic classes son más comprimibles pero son estáticas. Imposible
hacer --palette-solid cambie con data-color='affirm' desde
atomic classes sin generar ×N variantes en build.
¿Por qué inventar "TSC" y no usar @scope nativo de CSS?
@scope (CSS Cascading Modules L6) es bleeding-edge: Chrome 118+,
Firefox 128+, Safari aún no. No es production-ready 2026.
Cuando @scope sea universal, TSC podría re-implementarse encima de él. La superficie del config (declarations[] + scope) seguiría igual; sólo cambiaría el CSS emitido.
¿Por qué event: en TSC y no usar el data-motion-ref del doc motion?
Tres razones:
- TSC ya existe y funciona.
data-motion-refrequeriría un atributo DOM nuevo, runtime para inyectarlo, registry separado. - Sema ya emite
data-event. Reutilizar es 0 coste arquitectónico. - Composable:
scope: ['event:announce', 'color:affirm']permite diferenciar la animación según valencia.data-motion-refperdería eso o requeriría keys más complejas.
¿Cuál es el siguiente paso?
Pendientes deferred:
- Migrar los 5 palette consumers (button, checkbox, switch, radio-group,
toggle-group) a
declarations[]para uniformidad. - Implementar el primer caso real de
scope: 'event:*'(e.g. toast bg-during-announce). - Decidir si colapsar la capa 4 (component-color) — defer hasta que un consumer pida ese punto de extensión.
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-05-27.md— el journal de cómo llegamos aquí.src/uix/eidos/eidos-motion.md— propuesta motion (deferred, partially superseded by TSC).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
Los 15 componentes con data-color están en TSC. No hay
excepciones arquitectónicas — TSC v2.2 cubre las 3 patrones que
antes vivían fuera del modelo:
| Patrón | Solución TSC v2.2 | Componentes |
|---|---|---|
data-color per-parte (no en root) |
parts: ['x', 'y'] en RecipeTokenMultiDeclaration (multi-part scope) |
select (trigger + content) |
| Composite (variant × color) | scope: ['variant:X', 'color:Y'] (TSC v2 composite) |
avatar (root + badge) |
| Cross-recipe override desde ancestor | composition: { foreignRecipe: { targetSelector, tokens } } |
toggle-group (modifica Toggle's palette) |
El guard universal forbids palette-derived tokens at :root scope
(en recipe-css-contract.test.ts) sigue activo como defensa
secundaria en el CSS final, pero la fuente de verdad es el contrato
de tipos.
18.1 Cuándo se añadió cada extensión
- Multi-part scope (TSC v2.2): permite que un token cascadee sobre
más de un selector raíz. Necesario cuando
data-colorvive en parts distintos por razones de portal/cascade (Select Content vive fuera del árbol DOM del Trigger). - Composition (TSC v2.2): permite que un recipe declare overrides de los tokens de OTRO recipe, scoped a sus propias condiciones. Necesario para wrappers compositivos (toggle-group, eventual button-group, nav-menu, etc.).
Ambas extensiones se validan con el mismo pipeline TSC (scope algebra + cross-axis collision detection + auto-inferred deps).
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. Correcciones del engine de theming (2026-06-01)
Dos bugs del engine de theming detectados al construir el tema
untitled-ui (web/routes/temas/untitled-ui) y corregidos a nivel
engine (no parcheados en el theme), de modo que aplican a todos los
themes y consumidores.
20.1 — Densidad inerte (data-density no hacía nada)
Síntoma: cambiar data-density entre compact / comfortable /
spacious no movía nada en pantalla. El sistema de densidad parecía
muerto.
Causa: el generador emitía los escalares de densidad
(--density-scale, --density-space-scale, --density-control-scale,
--density-content-scale) y los redeclaraba por [data-density='…'],
pero las primitivas --space-* y --control-height-* eran px fijos
que nunca los consumían. Los escalares existían y cambiaban, pero
ningún token los usaba → cero efecto visible.
Fix (lib/render-css.ts): nuevo helper
appendDensityScaledDeclarations que emite --space-{n} y
--control-height-{k} como calc(<valor> * var(--density-{space|control}-scale)).
El valor cero se emite tal cual (0px). A comfortable el escalar es
1, así que el resultado es idéntico al valor crudo — cero regresión
para quien nunca cambia de densidad. Las primitivas de tamaño
(--size-{k}-*) y el padding de los recipes heredan el escalado porque
referencian var(--space-*) / var(--control-height-*).
Resultado (verificado): a compact el espaciado y las alturas se
reducen (×0.84 / ×0.90), a spacious crecen (×1.16 / ×1.12).
Nota: solo se escalan
spaceycontrol-height(los dos ejes con escalar dedicado y mapeo claro). La tipografía NO se escala con densidad — igual que Radix Themes / Untitled UI, la densidad afecta a ritmo y altura de controles, no al cuerpo de texto. El zoom global que SÍ escala la tipografía es un eje aparte (data-scaling) — ver §23.Actualización (eje de scaling): los escalares
--density-scaley--density-content-scaleque el generador emitía originalmente fueron eliminados al introducir el ejescaling(§23). La densidad hoy emite solo--density-space-scaley--density-control-scale; el helper se generalizó aappendScaledMetricDeclarations, que componecalc(<raw> * var(--density-…-scale) * var(--scaling))— densidad y scaling se multiplican.
20.2 — contrast ilegible sobre sólidos
Síntoma: el texto de los botones / badges / banners / cards de
variante solid salía oscuro sobre un fondo saturado oscuro
(p. ej. botón primario del base: texto purple-12 #402060 sobre
purple-9 #8e4ec6 ≈ 2:1, ilegible).
Causa: el slot de color contrast mapeaba por defecto al step 12
("texto de alto contraste", pensado para fondos CLAROS), y los recipes
usan --color-{role}-contrast como color de texto SOBRE el sólido
(step 9). Step 12 sobre step 9 = oscuro-sobre-oscuro.
Fix (lib/render-css.ts, loop de slots en renderThemeCss): el slot
contrast, cuando usa el valor por defecto, ahora resuelve a
var(--color-content-on-solid, var(--primitive-{role}-12)) — el color
on-solid del theme (blanco), con el step 12 como fallback. Un override
explícito del slot (roles: { x: { scale, slots: { contrast: '1' } } })
se respeta verbatim, así que roles monocromos que invierten su texto
(p. ej. un primario carbón que apunta contrast al step 1) siguen
funcionando.
--color-{role}-contrast se consume exclusivamente como fg sobre
sólidos (button / badge / banner / card / calendar-range / color-picker
ring) — verificado por grep — así que el cambio es seguro y no afecta a
ningún uso de "texto oscuro sobre fondo claro" (ese es el slot text,
step 11).
Verificación
npx vitest run src/uix/eidos: sin regresión — las únicas fallas son 3 pre-existentes (wordshuérfanos + wrappers, track aparte), confirmadas con baseline (git stashdel cambio). El testactive-eidos-configse actualizó para asertar la nueva forma density-aware de--space-4/--control-height-xxs.npm run generate:eidos-cssregenerado (la densidad vive en el CSS estático precompilado; elcontrastvive en el bloque de tema runtime).
21. Modelo de color de dos niveles (RFC — RESUELTO en §25)
Resuelto (2026-06-02). El modelo de color quedó decidido — ver §25. Se adoptó "paleta rica + capa semántica de alias / auto-derivación" y se descartó "intent = ancla de un solo color" (Radix no lo hace, y con una paleta rica el problema que motivaba el ancla desaparece). Lo de abajo se conserva como registro histórico de la propuesta original.
Tras el sprint de theming surgió una observación de fondo (comparando con
Radix Themes): hoy cada rol de color exige una escala de 12 pasos, incluidos
los 5 intents evaluativos (affirm / fulfill / risk / threat / loss).
Eso obliga a autorar ramps a mano para hues fuera de la librería base (12
escalas) y es propenso a error — un intent es conceptualmente un color, no
un ramp interactivo.
La propuesta (dos niveles: accents/neutral ricos + intents de un solo color
ancla con slots derivados por color-mix(), más ampliar la librería hacia
paridad Radix) está documentada como RFC en
COLOR_MODEL_RFC.md. (Estado original: propuesta.
Resuelto en §25 — se adoptó paleta rica + alias / auto-derivación y se
descartó el ancla de un solo color.)
22. Mejoras pendientes del theming
Auditoría completa 2026-06-01:
THEMING_AUDIT_2026-06-01.md— informe priorizado (P0–P3) en 6 frentes. Incluye defectos reales verificados (tokens de foundation inexistentes,neutralilegible en dark, alpha scales fabricadas, tokens de densidad muertos, huecos de tests) más todo lo de abajo.
Backlog vivo de mejoras al sistema. Ordenado por impacto, no por prioridad.
-
✅ Modelo de color — paleta + roles/intents derivados (mayor · resuelto 2026-06-02) — adoptado el modelo Radix-style: paleta de 31 escalas (diseñable por el tema) + roles de jerarquía como alias explícito + intents auto-derivados por convención del libro (identidad = step 9). Se descartó el "intent = ancla de un solo color". Modelo completo en §25 /
COLOR_MODEL_RFC.md. -
✅ Variant
surface/softvía alpha en vez de tinte opaco (resuelto 2026-06-02) — el tintesoftpor rol (Button + Badge{role}-soft-bg) se computaba opaco (step-1track+color-mixopaco en hover) → no componía sobre fondos no uniformes. Resuelto con tokens derivados--color-{role}-surface(=--primitive-{role}-a2) +--color-{role}-surface-hover(=a3), translúcidos por construcción. Ver §24.2.
23. Eje de scaling (zoom global) — 2026-06-02
Eje independiente de la densidad, en paridad con el scaling de
Radix Themes. Diseño completo en SCALING_RFC.md.
23.1 — Qué es y en qué se diferencia de la densidad
Son dos ejes ortogonales que se multiplican:
| Eje | Atributo | Qué mueve | Tipografía |
|---|---|---|---|
| Densidad | data-density (compact / comfortable / spacious) |
ritmo de layout (space) + altura de controles (control-height) |
NO — el cuerpo de texto queda fijo |
| Scaling | data-scaling (90 / 95 / 100 / 105 / 110) |
zoom global: space + control-height + font-size + icon-size |
SÍ — escala el cuerpo de texto |
Densidad = "más/menos aire entre cosas, controles más bajos, mismo texto". Scaling = "agranda/encoge todo proporcionalmente", igual que el zoom del navegador pero acotado al subárbol del tema. Conceptualmente: densidad es una decisión de diseño (compacto vs holgado); scaling es una decisión de accesibilidad / preferencia de tamaño del usuario.
23.2 — Qué escala y qué NO
--scaling (default var(--scaling-100) = 1) multiplica solo
métricas en px cuyo crecimiento proporcional es correcto:
- ✅
--space-{n},--control-height-{k}(también llevan el escalar de densidad) - ✅
--font-size-{name},--icon-size-{k}
NO escala (a propósito):
- ❌
line-height— es un ratio sin unidad; escalar elfont-sizeya escala el interlineado real. - ❌
--radius-*,--border-*, sombras — un zoom de UI no engorda bordes ni radios proporcionalmente (Radix tampoco lo hace); mantenerlos fijos conserva la nitidez del chrome.
23.3 — Generación + proyección
lib/render-css.ts:
appendScalingDeclarationsemite las constantes--scaling-{90..110}(STATIC_SCALINGenlib/primitives/static.ts) +--scaling: var(--scaling-100)en:root.appendScaledMetricDeclarations(declarations, prefix, record, densityScaleVar?)envuelve cada métrica encalc(<raw>[ * var(--density-…-scale)] * var(--scaling)). El valor cero se emite tal cual.spaceycontrol-heightpasan eldensityScaleVar;font-sizeeicon-sizeno (no dependen de densidad).renderScalingBlocksemite[data-scaling='90'] { --scaling: var(--scaling-90); }… para los niveles ≠100. Como todas las métricas leenvar(--scaling), reescribir esa única variable reproyecta el subárbol entero — cero redeclaración por token.
A 100 el escalar es 1 → idéntico al valor crudo, cero regresión
para quien no toca scaling.
23.4 — API (ActiveEidos)
Simétrica a density:
createActiveEidos({
scaling: '110', // estático
// o reactivo:
scalingSource: { get: () => prefs.scaling, onChange: (fn) => prefs.subscribe(fn) }
})
ActiveEidos escribe data-scaling en el target junto a data-theme /
data-mode / data-density, y lo limpia en dispose(). La preferencia
viaja por ActiveEidosPreferenceSource.getScaling(); DEFAULT_SCALING
es '100'.
24. Correcciones P2 del engine (2026-06-02)
Dos defectos de calidad de la auditoría
(THEMING_AUDIT_2026-06-01.md P2-2, P2-4),
corregidos a nivel engine para que apliquen a todos los temas.
24.1 — Texto on-solid ilegible sobre sólidos claros (P2-2)
Síntoma: el texto de los botones / badges solid de roles con sólido
claro (amarillo, ámbar, risk=naranja) salía blanco sobre claro —
naranja-9 con blanco ≈ 2.3:1, sub-AA.
Causa: el slot contrast (color del texto SOBRE el sólido) resolvía por
defecto a --color-content-on-solid (blanco) para todos los roles. Correcto
para sólidos oscuros (purple, red), ilegible para sólidos claros.
Fix (render-css.ts): pick por luminancia. En generación, el engine
calcula la ratio de contraste WCAG (gamma-linealizada, wcagContrastRatio)
entre onSolid y el step-9 del rol. Si onSolid falla (< 3:1), el slot
resuelve a --color-content-on-solid-contrast (un oscuro, nuevo semantic
opcional content.onSolidContrast, #1c1917 en base) en vez de blanco.
risk (orange #f76b15) → texto #1c1917 = 5.89:1 ✓ (era ~2.3:1 con blanco)
primary (purple) → texto #fff = 5.18:1 ✓ (se mantiene)
threat (red) → texto #fff = 3.91:1 ✓ (convención, ≥3:1)
Solo risk volcó a oscuro en el tema base; el resto mantiene blanco. Un
override explícito slots.contrast se respeta verbatim (p. ej. neutral
sigue en step-12). El umbral 3:1 es el mínimo AA para UI / texto grande —
ancla principista, no número mágico.
24.2 — Superficies tintadas opacas → translúcidas vía alpha (P2-4)
Síntoma: el fondo de la variante soft por rol (Button + Badge) era
opaco → al superponerse sobre fondos no uniformes (filas a rayas, imágenes,
gradientes) tapaba el fondo en vez de teñirlo.
Causa: {role}-soft-bg = var(--color-{role}-track) (step-1, opaco) y el
hover un color-mix opaco.
Fix: nuevos tokens de rol derivados, translúcidos por construcción (usan el alpha compositing-inverse §P1-1, consistente con el sólido):
--color-{role}-surface = var(--primitive-{role}-a2) /* soft bg */
--color-{role}-surface-hover = var(--primitive-{role}-a3) /* soft bg hover */
Button y Badge soft consumen esos tokens. Sobre la superficie por defecto se
ven casi idénticos (a2 ≈ el step-1 anterior); sobre fondos no uniformes ahora
componen correctamente.
Toast y Tabs NO se tocaron — aunque la auditoría los listó, son tarjetas: el toast tiene fondo neutral opaco y la tab-list un
surface-defaultya translúcido. La opacidad ahí es correcta por diseño (no quieres ver el contenido de la página a través de un toast). La fórmula opaca que §22 documentaba mal era la desoft-bg-hoverde Button, ya migrada.
25. Modelo de color — paleta + roles/intents derivados (2026-06-02)
Decisiones cerradas sobre el modelo de color. Resuelve el RFC §21. Es, 1:1, el modelo de Radix Themes: una paleta de escalas + una capa semántica de alias + override por componente. Lo único propio es que los intents (capa del libro) auto-derivan de la paleta por convención.
25.1 — Las tres capas
| Capa | Qué es | Cómo se define |
|---|---|---|
| Paleta | librería de escalas de 12 pasos | --scale-{name}-{step} (+ alpha --scale-{name}-a{step}) · directamente usable · diseñable por el tema |
| Roles (jerarquía) | primary · secondary · tertiary |
alias explícito a una escala (decisión de marca · obligatorio) |
| Intents | neutral + affirm/fulfill/risk/threat/loss |
auto-derivados de la paleta por convención del libro · identidad = step 9 · slots derivan normal · override opcional |
Los componentes consumen la capa semántica (--color-{role}-{slot}) y pueden
override su color a cualquier escala vía la prop color / data-color.
25.2 — Paleta (la fuente, diseñable)
- Escalas funcionales de 12 pasos:
1-2fondos ·3-5componente ·6-8bordes ·9sólido ·10hover ·11-12texto. El representativo de una escala es el step 9 (el sólido), NO el medio geométrico (step 6, que es un tono de borde lavado). - Directamente usable: cualquier paso es
var(--scale-{name}-{step})(p. ej.var(--scale-green-10)). No existe alias corto--{name}-{step}: dos formas para el mismo valor crearían ambigüedad sobre cuál es la canónica. - Diseñable: la paleta la trae el tema (dominio del diseñador). El framework
envía una paleta por defecto de 31 escalas (valores exactos de Radix
Colors, en
lib/themes/radix-scales.ts+base.ts) — pero es "la paleta", no "la de Radix": un tema la reemplaza/amplía. Un color de marca se añade como una escala (autorada o generada), nunca como un valor inline suelto.
25.3 — Roles de jerarquía (alias explícito)
primary / secondary / tertiary son decisiones de marca sin color canónico:
el tema DEBE mapearlos a una escala de la paleta. Pueden llevar override de
slots (p. ej. un primario monocromo con slots: { contrast: '1' }).
25.4 — Intents auto-derivados (convención del libro)
- Los 6 intents tienen color canónico definido en el libro Diseñando lo que
ocurre. La convención
INTENT → escalavive enCANONICAL_INTENT_SCALES(lib/config-types.ts):neutral→gray · affirm→teal · fulfill→green · risk→amber · threat→red · loss→plum. - Un intent omitido del mapa de roles auto-deriva de la paleta por esa
convención (
completeColorRoleMap, consumido porrender-cssy la validación). Su identidad es el sólido (step 9); los 9 slots derivan normal. La paleta debe proveer esas escalas (o el tema overridea el intent mapeándolo explícito). - Tipos: en
ColorRoleMapla jerarquía es obligatoria y los intents opcionales —Record<HierarchyColorRole, V> & Partial<Record<Intent, V>>. neutrales el 6º intent pero sin valencia: funciona como gris de superficies/bordes/texto, por eso auto-deriva a una escala gris (no es una señal valenced). Las 5 valenced llevan la carga.- Doctrina: el color EXPRESA el intent, no lo define — la valencia/activación la lleva la capa sema (sonido/haptic/motion); el color solo aporta la identidad de hue.
25.5 — Override por componente
Cualquier componente acepta color="..." (cualquier escala de la paleta) → la
cascada _accent-* del recipe remapea sus tokens a esa escala para esa
instancia. Equivalente a <Button color="grass"> de Radix.
25.6 — Por qué se DESCARTÓ el "ancla por rol"
El RFC §21 proponía declarar un intent como un solo hex ({ anchor }) y derivar
los slots inline con color-mix(). Se descartó: Radix no lo hace (genera una
escala desde un hex y la aliasa), y con una paleta rica el problema que lo
motivaba (autorar 12 pasos a mano para loss → el bug de loss=azul) desaparece
solo: loss simplemente aliasa la escala plum, que ya existe en la paleta.
El modelo final es paleta rica + alias / auto-derivación, no ancla.
25.7 — Framework vs tema
- Framework: envía la paleta por defecto (31 escalas Radix) — para el tema base y para quien no traiga la suya.
- Tema de marca (p. ej. Grafito): trae su propia paleta + mapea la jerarquía; los intents auto-derivan. (= Radix Themes: Radix trae su paleta, tú puedes traer la tuya.)
26. Theme builder en runtime — eidos.applyColorScheme (2026-06-04)
El RFC §6.2 (un seed → todo el sistema) está implementado como API de primera clase. Un app re-tematiza desde UN color de marca con una llamada, sin tocar el CSS:
const result = eidos.applyColorScheme('#8e4ec6', {
variant: 'tonal', // 'tonal' | 'vibrant' | 'monochrome'
temper: 0.12, // cohesión de intents (mantiene hue)
overrides: { tertiary: '#3e63dd' } // fija un rol; el resto deriva del seed
})
eidos.clearColorScheme() // revierte a los primitives del tema
Qué hace: compone el motor uix.color — deriveScheme (Material 3 → jerarquía
- neutral) →
generateScale(12 pasos por rol) → APCA on-solid → alpha compositing-inverse — en un override de la capa de binding--primitive-{role}-*(+--color-{role}-contrast). Override del binding reproyecta cada--color-{role}-{slot}y el chrome neutral (surface/content/border) aguas abajo. La paleta de 31 escalas y los slots NO se tocan.
Capas (matemática pura → composición pura → aplicación DOM):
| Pieza | Dónde | Qué |
|---|---|---|
| matemática | arts/color ($color) |
deriveScheme / generateScale / temper / APCA / alpha — pura, isomórfica |
| composición | eidos/lib/build-scheme.ts |
buildScheme(seed, opts) → { variables, roles } — pura, testeable |
| runtime | ActiveEidos.applyColorScheme |
resuelve donantes + background del tema activo, escribe el bloque de estilo, sigue light/dark |
Sigue el modo: las curvas-donantes + el background salen del tema activo, así que
el esquema se re-deriva en cada apply() (cambio de modo → ramp light vs dark). El
bloque uix-eidos-scheme se escribe después del de tema para ganar en orden de
cascada.
Override por rol + temper = doctrina de §25.4 / RFC §6.2: la jerarquía deriva
(override per-rol opcional), los intents mantienen su hue y solo afinan
temperatura. applyColorScheme devuelve BuildSchemeResult (steps hex + stepsOklch
- solid / on-solid / pinned por rol) para introspección de UI.
Wide-gamut: el bloque apila hex fallback + oklch() por paso (vía
schemeDeclarations), y generateScale retiene el OKLCH raw sin clamp — un seed
vívido (croma > sRGB) sale wide-gamut en P3. Ver §27.
Demo en vivo: /temas/color (el builder usa el mismo buildScheme). Tests:
build-scheme.test.ts + active-eidos.test.ts.
27. Salida wide-gamut OKLCH (default-on) (2026-06-04)
RFC §7 estrategia A, implementada por defecto. Cada paso de paleta se emite dos
veces: el hex como fallback universal + un hermano oklch() que gana donde el
navegador lo soporta (Chrome 111+ / Safari 15.4+ / Firefox 113+).
:root {
--scale-purple-9: #8e4ec6; /* fallback sRGB */
--scale-purple-9: oklch(0.5556 0.1829 305.86); /* gana -> gamut del display */
}
- Solo las hojas opacas
--scale-{name}-{step}ganan el hermano; las capas--primitive-*/--color-*sonvar()(heredan) y las alpha siguen comocolor-mix/ rgba. Valores vacíos / no-color no reciben hermano. - sRGB idéntico: el hex y el
oklch()derivado de un sRGB pintan el mismo color (verificado:--scale-purple-9→oklch(...)pinta#8e4ec6). El wide-gamut REAL aparece cuando el origen excede sRGB (tema OKLCH / esquema generado vívido). La paleta Radix shipped es sRGB → idéntica hoy; wide-gamut visible de la paleta = Fase 3. - Default-on, sin flag: es el comportamiento del framework.
render-css.ts > appendColorScaleDeclarations. - El generador SÍ produce wide-gamut REAL:
buildScheme/applyColorScheme(§26) retienen el OKLCH raw degenerateScale(sin clamp), así que un seed cuyo croma excede sRGB renderiza más saturado en P3 que su hex fallback — el bloque apila hex +oklch()por paso víaschemeDeclarations(result, { fallback }). El demo/temas/colorlo demuestra con el slider vivacidad P3 (badge «fuera de sRGB → P3» al cruzar el gamut; verificado: croma 0.18 → 0.31).
28. Accesibilidad forced-colors + ramp de bordes (2026-06-05)
Forced-colors (Windows High Contrast) — bajo @media (forced-colors: active) el
navegador auto-mapea bordes / texto / fondos a system colors (forced-color-adjust: auto), PERO elimina box-shadow — y el focus ring de eidos (--focus-ring) es un
box-shadow, así que el foco desaparecía. Fix: la foundation emite siempre
@media (forced-colors: active) {
:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
}
Los componentes que ya enfocan con outline (p. ej. Button) conservan el suyo por
especificidad; este es el fallback para los de box-shadow. renderForcedColorsBlock
en render-css.ts.
prefers-contrast: more (macOS "Aumentar contraste", etc.) — bloque aparte que
refuerza el chrome neutral para quien pide más contraste: bordes a pasos más
fuertes (subtle/default/strong → neutral 7/8/9) + texto de-enfatizado más legible
(secondary → 12, muted → 11). Sólidos + texto primario ya son alto-contraste. Usa
:root:root (especificidad 0,2,0) para ganar al :root del tema sin depender del orden;
referencia --primitive-neutral-* (resuelven del cascade; si un tema los omite, la
declaración se ignora — degrada con gracia). Estrictamente aditivo (gated por el media
query) y estrictamente MÁS fuerte, así que no puede regresar el look por defecto.
renderPrefersContrastBlock en render-css.ts.
Ramp de bordes — el slot de rol border pasó de step 6 → step 7. En la escala
funcional de Radix el 6 es un separador sutil y el 7 es el UI element border; el 6
se leía lavado en bordes reales (outline / surface / controles). element / hover /
active (3 / 4 / 5) se mantienen (canónicos de Radix para component-bg).
DEFAULT_COLOR_ROLE_SLOT_STEPS. Verificado en navegador (checkbox + token
--color-{role}-border → step 7).
29. Profundidad (depth) — canal unificado + eventful (2026-06-05)
La profundidad es un canal unificado y eventful, no tres sistemas sueltos (sombra +
superficie + z). Guía canónica: DEPTH_ENGINE_RFC.md. Dos momentos:
- Estado —
data-depth='{plane}'aplica un plano en reposo (flush · raised · overlay · modal · recessed) que cohere superficie + sombra + z. Los tokens--depth-{plane}-{cue}componen los primitivos existentes (--color-surface-*,--shadow-*,--z-index-*), así que la mezcla es mode-aware gratis. La regla[data-depth]aplica las señales aditivas seguras (box-shadow= gotashadow+ rim-lighthalo, mász-index);surfacequeda opt-in (no pisa fondos de componente). Elhaloes un rim de borde superior computado en oklab (color-mix(in oklab, white N%, transparent)): invisible sobre superficies claras (manda la gota), señal de elevación sobre oscuras — la respuesta mode-adaptive a "la sombra miente en dark". - Evento — al emerger la sombra crece desde plano → la de reposo (sube); al
presionar se aplana (recede). Vive en la firma (
present-rise/press-squeezesobredata-event-*), coordinado con motion + sound + haptic desde un solo evento. Generic: un elementoflush(sin sombra) = no-op. Degrada conprefers-reduced-motion.
Jaula abierta: el set de planos es config-driven (EidosConfig.depth.planes —
añade/renombra/retunea); los primitivos siguen accesibles (box-shadow/z-index crudo a un
paso); la capa eventful es aditiva y anulable (sobreescribe keyframes/signatures). Demo en
vivo: /temas/profundidad.
Adopción (hecha, 2026-06-05): los componentes elevados consumen el canal — sus tokens de
sombra (--{c}-…-shadow en recipes/base.ts, o el box-shadow directo) componen
var(--depth-{plane}-shadow), var(--depth-{plane}-halo), así que el halo llega a
popover · dialog · drawer · dropdown/context/navigation-menu · menubar · select · tooltip ·
card · combobox · command · link-preview · words. La z-index la sigue gestionando cada
componente (las bandas z son más finas que los 5 planos) — la adopción es solo de la señal
sombra+halo, cero riesgo de stacking. La adopción plena vía atributo data-depth (que
unificaría también la z) queda como opción futura.
Atmósfera (frost, hecha 2026-06-05): cue blur por plano + regla opt-in
[data-depth='{plane}'][data-frost] → superficie translúcida (color-mix 80%) +
backdrop-filter: blur(var(--depth-{plane}-blur)). Gated, nunca por defecto (un overlay opaco
sigue opaco salvo que pida data-frost). El builder runtime ActiveEidos.applyDepth(planes) /
clearDepth() (+ buildDepth puro, exportado de $uix/eidos) retune cualquier cue de plano
en vivo — hermano de applyColorScheme / applyTypeScale. Demo: /temas/profundidad
§Materiales.
Pendiente (menor): el cue scrim está disponible como token (--depth-{plane}-scrim) pero
sin regla cableada — el backdrop dim de los modales lo gestiona hoy cada componente.
30. Forma (shape) — continuidad + familias + anidado + eventful (2026-06-05)
La forma es un canal, no un número de border-radius. Guía canónica:
SHAPE_ENGINE_RFC.md. La magnitud sigue en --radius-* (intacta); shape añade los ejes
que todos dejan planos. El primer squircle-como-token de la web (el campo entero es
arco + estático; la continuidad solo existía en Apple, atada a plataforma).
- Continuidad —
--shape-smoothing(exponente superelipse: 1 = arco, 2 = squircle) + familias víadata-shape='{family}'→corner-shape:rounded(round) ·continuous(superellipse(var(--shape-smoothing))) ·cut(bevel) ·scoop. Opt-in (no pisa círculos/píldoras) y progresivo: degrada al arco deborder-radiusdonde no haycorner-shape(Chromium 2025+). - Armonía anidada —
[data-shape-nest]derivaborder-radius: max(0px, var(--shape-outer-radius) − var(--shape-nest-gap)): el hijo queda concéntrico al padre (que expone su radio en--shape-outer-radius). El concéntrico de 4 esquinas requiere radios finitos: afull(9999px) el radio se recorta a ½ de la dimensión menor de cada elemento, así que un hijo de proporción distinta no puede serlo en las 4. Pero sí en las superiores (radio_card − gap) si las inferiores quedan rectas — la geometría del reproductor iOS. El demo/temas/formalo mide (ResizeObserver, porque el cap es valor usado no legible en CSS) y lo aplica al top de la carátula. - Eventful (dos momentos) — la forma en reposo (
data-shape) + el morph al pulsar: la firmapress-squeezecuadra la esquina un instante (--shape-smoothing2→3→2, registrado con@propertypara que interpole). Cross-modal: un evento mueve escala + sombra + esquina. Degrada conprefers-reduced-motion. No-op en familias no-continuous. - Jaula abierta — escala + familias config-driven (
EidosConfig.primitives.shape); elborder-radiuscrudo siempre a un paso; builder runtimeActiveEidos.applyShape(seed)/clearShape()(+buildShapepuro, exportado de$uix/eidos) para dialar continuidad / nestGap / familias en vivo. Demo:/temas/forma.
Pendiente (futuro): adopción por componentes (hoy las recipes usan --radius-* con arco;
optar a data-shape='continuous' en las superficies rectangulares es una pasada separada, con
cuidado de no squircle-izar avatares/píldoras).
31. Estructura (espacio · densidad · escala) — el espacio como ritmo (2026-06-05)
Los sistemas estructurales (a diferencia de los expresivos) son solo-estado — el
escenario, no el suceso. Guía canónica: STRUCTURE_ENGINE_RFC.md. Tres ejes ortogonales:
- Densidad —
[data-density='compact'|'comfortable'|'spacious']reescala el layout (spacecontrol-height) sin tocar la legibilidad del texto. 3 niveles × 2 ejes (config-driven).
- Scaling —
[data-scaling='90'..'110']es el zoom global (incluye tipografía; paridad Radix), compone con densidad. - Espacio (el ritmo) —
--space-{key}se emite comocalc(value · var(--density-space-scale) · var(--scaling)). El value ya no es solo px plano:buildSpaceScale(seed)(puro) +ActiveEidos.applySpacing(seed)/clearSpacing()lo regeneran desde una unidad base (base × N, modular) y opcionalmente fluido (growth > 1→ cada pasoclamp()que respira entre 480 y 1280px, reusando elfluidClampdel type scale). Preserva la composición density × scaling. Hermano deapplyTypeScale— opt-in sobre la escala authored (STATIC_SPACEintacta). Exportado de$uix/eidos. Demo:/temas/estructura.
Doctrina: el espacio es ritmo, no una tabla de píxeles. Modular + fluido + compuesto con densidad × zoom desde una semilla. El campo entero shippea una escala plana estática; el espacio fluido (que casi nadie hace para el espacio, solo para el tipo) + los tres ejes integrados son el diferencial. Estructural = solo-estado (sin dos momentos — el modelo eventful es de los canales expresivos).
Pendiente (futuro): applyTheme(seed) — una semilla que componga tipo + espacio (ritmo
compartido), capstone del cuarteto→quinteto de builders.
Última revisión: 2026-06-05. Si algo en este doc no coincide con el código, el código gana — pero abre un issue para que actualicemos el doc.