48 KiB
Eidos
Eidos es la capa visual de UIX. Cubre lo que en la rama muerta air/
era el "runtime visual" más el sistema de tokens — sin heredar código.
Lee del DOM lo que las capas anteriores han escrito (morfo runtime + sema
visual channel) y aplica estilos, animaciones y wrappers ergonómicos.
Morfo declara la genética
↓
Soma transcribe el comportamiento → DOM (data-state, data-color, aria-*)
↓
Sema emite señales perceptivas → DOM (data-event-*) durante el hold
↓
Eidos aplica el visual: tokens, themes, recipes, archetypes, wrappers
Eidos nunca importa internals de soma ni de sema. Su fuente de verdad es lo que está escrito en el DOM (parts, data-attrs, ARIA, archetypes, event signals) y los tipos públicos de Soma que necesita para componer wrappers.
No es solo CSS
El primer mental model fue "eidos = CSS reactivo". Insuficiente: hay concerns visuales puros (variant, size, layout flags, icon slots) que no son parte del comportamiento headless pero sí son ortogonales al CSS. Eidos los aloja como wrappers Svelte sobre el Soma.
src/uix/eidos/
active-eidos.svelte.ts → runtime/contexto visual y generacion CSS
index.css → entrypoint que importa todo el CSS
archetypes.css → reglas comunes a [data-archetype=*]
events.css → reacciones a [data-event-*] (sema visual)
generated/base.css → salida estatica generada desde EidosConfig base
themes/fonts.css → font faces usados por el theme base generado
lib/ → soporte de config, recipes, contrato CSS y tipos compartidos
components/{x}/ → recipe + wrapper + tipos por componente
{x}.css recipe CSS (selectores [data-{x}], etc.)
{x}.svelte wrapper Svelte sobre el componente Soma
types.ts props del wrapper (extiende el contrato público Soma)
index.ts namespace publico (default + partes attached)
lib/ contiene el soporte puro de configuracion visual: primitivas, semantica
visual, themes, contrato CSS y render. generated/base.css es el primer
artefacto estatico generado desde esa configuracion (npm run generate:eidos-css).
Los antiguos contracts/ y themes/base/ CSS se retiraron del arbol activo:
el contrato publico se obtiene con ActiveEidos.getCssContract() /
renderContractCss() y el base visual sale de generated/base.css.
Los antiguos tokens/components/* tambien se retiraron: los nombres de
custom properties de recipe (--toast-*, --dialog-*, etc.) siguen siendo
el contrato estable, pero sus valores base viven en EidosConfig.recipes y
se generan en generated/base.css.
Runtime activo
Eidos tiene una clase activa:
ActiveEidoses el runtime activo y contexto visual de los wrappers Svelte. Gestiona configuracion visual pura, primitivas, roles semanticos, themes, validacion, render de CSS y persistencia. Conecta esa configuracion con los servicios deActiveUixcuando hay contexto:prefs,dom,langs,formaty helpers visuales comoresolve(...),breakpoint(...)oisBelow(...). SiapplyDomesta activo, inyecta/quita<style data-uix-eidos>usandouix.dom.
Regla de dependencia: un componente en src/uix/eidos/components/* no importa
getActiveUix() directamente. Consume ActiveEidos.require() y solo conoce la
superficie visual de Eidos.
Regla de ownership: ActiveEidos no crea servicios compartidos. En modo
contexto, ActiveEidos.create(...) obtiene esos servicios de ActiveUix; si
un wrapper requiere uno que no existe, lanza error. En modo explicito,
createActiveEidos(...) recibe servicios ya construidos. Con applyDom:false
queda como runtime sin escritura DOM: puede renderizar, serializar y validar
CSS sin insertar estilos.
Matiz importante: prefs, langs y format son opcionales durante la
construccion porque ActiveEidos tambien sirve para generar/serializar CSS.
Los getters activeEidos.prefs y activeEidos.langs si fallan temprano si un
wrapper visual los lee y no fueron inyectados. dom solo es obligatorio cuando
applyDom esta activo.
La configuracion vive en EidosConfig:
interface EidosConfig {
primitives: PrimitiveSet;
semantics: SemanticSet;
themes?: ThemeMap;
recipes?: RecipeTokenSet;
}
Para authoring incremental sobre el theme base existe
EidosConfigPatch. No sustituye al objeto completo: lo extiende de
forma profunda, preservando lo no declarado y reemplazando arrays completos
(por ejemplo fallbacks de una familia tipografica).
const config = createThemeBaseEidosConfig({
primitives: {
typography: {
families: {
primary: { family: 'Inter' }
}
}
},
semantics: {
color: {
roles: {
primary: 'blue'
}
}
}
});
Si la app quiere partir de cero, usa defineEidosConfig() con un
EidosConfig completo y lo pasa a ActiveEidos.create({ config }). Si quiere
partir del theme base, usa createThemeBaseEidosConfig(patch) o directamente
ActiveEidos.create({ themeBase: patch }).
primitives contiene las bases no semanticas: escalas de color de 12 pasos,
size map canonico, espacios, alturas de control, radios, borde, opacidad,
z-index, focus ring, layout, tipografia, sombras, motion e iconos.
semantics.color.roles mapea esas escalas a los roles canonicos:
primary · secondary · tertiary · neutral · affirm · fulfill · risk · threat · loss
No hay danger, success, warning ni info: esos nombres pertenecen a
otros modelos. En Eidos los componentes hablan por intents y jerarquia visual.
recipes contiene los aliases publicos de cada recipe activa. No define
selectores ni estados de componente: solo valores para custom properties que
las recipes CSS consumen. Por ejemplo, recipes.tooltip['content-z'] genera
--tooltip-content-z; recipes.dialog['overlay-bg'] genera
--dialog-overlay-bg. Asi los componentes mantienen su CSS estable y el tema
puede persistir/editar esos valores desde el mismo objeto de authoring.
El contrato recipe/CSS se valida en recipe-css-contract.test.ts: si una
recipe CSS consume --{component}-*, ese alias debe existir en
EidosConfig.recipes; y si el theme base declara un alias publico, debe estar
consumido por el componente o por otro token compuesto. No hay reservas
fantasma ni paletas que el componente no pueda activar desde su API real.
Para editores de theme, ActiveEidos.listRecipes() enumera los componentes
con recipe tokens y ActiveEidos.getRecipeTokens(component) devuelve una
copia defensiva del mapa de aliases. No muta el config interno.
La decision vigente es mantener recipes como mapa plano de aliases
(RecipeTokenSet). Subir una recipe a tipo estructurado propio solo se
justifica cuando exista un builder o consumer real que necesite semantica
interna; mientras las recipes sigan siendo CSS plano, el contrato publico es
el custom property generado.
ActiveEidos genera tambien la escala alpha de cada paleta fisica:
--scale-blue-a1 … --scale-blue-a12
--primitive-primary-a1 … --primitive-primary-a12
Si el theme declara color.alphaScales, esos valores se respetan. Si no, se
derivan desde el paso sólido de la escala (--scale-{name}-9) con una
progresion canonica de opacidad. Esto permite usar tintas translucidas para
overlays, rings, hovers suaves o scrims sin que cada recipe invente su propia
formula.
Size canonico
Size es discreto y estable:
xxs · xs · sm · md · lg · xl · xxl · full
full es semantica de layout y no genera primitiva fisica. La config mapea
solo xxs..xxl a tokens coordinados:
--size-md-control-height
--size-md-font-size
--size-md-font-line-height
--size-md-font-letter-spacing
--size-md-icon-size
--size-md-padding-inline
--size-md-padding-block
--size-md-gap
--size-md-radius
La regla es: md no cambia de significado por viewport. Lo responsive decide
que size activo se usa (ResponsiveProp<Size>), no redefine los tokens de
md. Porcentajes, vw, dvh, clamp() o min() pertenecen a layouts
como full, panels y containers; no al size canonico de controles, iconos o
tipografia.
Primitivas transversales
El bloque static ya genera las primitivas compartidas que suelen aparecer en Radix Themes, Ark/Panda, Chakra, Tailwind o shadcn como foundation tokens:
layout: anchuras de container, padding inline de container, anchuras de contenido y ratios canonicos. Genera:--container-width-*,--container-padding-inline,--content-width-*y--aspect-ratio-*.density: escalascompact · comfortable · spaciouspara que las recipes puedan ajustar espacio, altura de control o contenido sin redefinir los tokens canonicos. Genera--density-{key}-*y aliases activos como--density-space-scale.border: anchurasnone · hairline · thin · medium · thick, estilossolid · dashed · dottedy aliases--border-width,--border-style,--border.opacity:0 · muted · disabled · scrim · overlay · hover · press · full.zIndex:base · raised · sticky · dropdown · popover · tooltip · modal · toast.shadow: escala fisica1..6más aliases semanticosnone · subtle · raised · overlaypor theme.
La regla es la misma que en size: estos tokens no cambian de significado por
breakpoint. Un componente o wrapper puede elegir otro token en un viewport
concreto, pero --shadow-3, --opacity-disabled o --z-index-modal siguen
siendo la misma coordenada del sistema.
El bridge responsive vive en ActiveDom/ActiveEidos: los wrappers usan
ActiveEidos.resolve(...), breakpoint(...), isAtLeast(...) e
isBelow(...) para decidir que token canonico aplica en cada viewport.
--container-width-md o --content-width-lg no se recalculan por responsive;
lo que cambia es la eleccion del token. La densidad sigue el mismo principio:
ActiveEidos proyecta data-density y publica scalars activos:
:root {
--density-compact-space-scale: 0.84;
--density-comfortable-space-scale: 1;
--density-spacious-space-scale: 1.16;
--density-space-scale: var(--density-comfortable-space-scale);
}
[data-density='compact'] {
--density-space-scale: var(--density-compact-space-scale);
}
Una recipe puede usar calc(var(--space-4) * var(--density-space-scale)).
--space-4 no cambia de significado; cambia la policy visual activa.
themes permite sobrescribir escalas, roles, superficies, contenido, bordes,
focus y sombras por theme. El theme base actual expone base-light y
base-dark; defaultActiveEidosThemeResolver usa el theme activo si existe
como id exacto, preserva ids externos ya cualificados (*-light, *-dark) y,
si no, prueba ${theme}-${mode}.
ActiveEidos no persiste CSS en disco. Si applyDom esta activo, escribe en
el DOM:
${styleId}-staticcon primitivas estables (renderStaticCss()).${styleId}-themecon el theme resuelto cuando procede de la config.
themeSource decide de donde salen los valores de theme:
'auto'(default): genera CSS si la config conoce el theme; si no lo conoce, asume que viene de CSS externo y deja solo los tokens static.'config': modo estricto; si el theme resuelto no existe en la config,renderThemeCss()lanza error.'css': nunca genera CSS de theme; el integrador aporta los valores con CSS externo que respeta el contrato de custom properties.
Para inspeccionar o publicar ese contrato, ActiveEidos expone dos superficies:
getCssContract()devuelve el contrato estructurado, typed, con cada token (name,cssVar,scope,category,path). Es la superficie pensada para editores de theme, validadores, tooling o persistencia de usuario.renderContractCss()renderiza ese mismo contrato como CSS vacío para documentar o bootstrappear themes externos.renderCssVariables()acepta un mapa de custom properties y lo convierte en CSS runtime validado contra el contrato de Eidos.
const tokens = eidos.getCssContract();
const recipes = eidos.listRecipes();
const tooltipTokens = eidos.getRecipeTokens('tooltip');
const contract = eidos.renderContractCss({
staticSelector: ':root',
themeSelector: "[data-theme='acme-light']"
});
El resultado no asigna valores reales; declara las custom properties que un theme CSS-only puede aportar o sobrescribir:
[data-theme='acme-light'] {
--scale-blue-9: ;
--scale-blue-a9: ;
--primitive-primary-9: ;
--primitive-primary-a9: ;
--color-primary-solid: ;
--size-md-control-height: ;
--border-width-thin: ;
--opacity-disabled: ;
--z-index-modal: ;
--shadow-3: ;
}
La persistencia usa un envelope versionado:
const document = eidos.toDocument();
const json = eidos.serialize();
const hydrated = createActiveEidos({
config: JSON.parse(json),
prefs,
dom
});
El documento tiene forma { kind: 'uix.eidos-config', version: 1, options }.
EidosConfig no lleva version dentro: sigue siendo configuracion
visual pura. Si el shape cambia en el futuro, se sube
EIDOS_CONFIG_DOCUMENT_VERSION; Eidos no intenta compatibilidad silenciosa
con versiones no soportadas. La app decide dónde guardar ese documento
(prefs, backend, archivo, etc.) y qué metadata de producto lo envuelve
(name, owner, timestamps).
Al hidratar un documento como runtime (config: document,
readEidosConfigFromDocument(...) o parseEidosConfigFromJson(...)), Eidos
valida también las options contra el contrato completo de EidosConfig.
parseEidosConfigDocument(...) queda como parser de envelope: confirma
kind/version/options, pero no convierte ese envelope en configuracion
visual usable por si solo.
Integracion normal dentro de un arbol con ActiveUix:
const uix = createActiveUix({
langs: { schema, defaultLocale: 'es' }
});
setActiveUix(uix);
ActiveEidos.create({
themeBase: {
semantics: {
color: {
roles: { primary: 'blue' }
}
}
},
themeSource: 'auto',
themeResolver,
styleId: 'uix-eidos'
});
config acepta un EidosConfig completo o un EidosConfigDocument.
themeBase acepta solo un patch sobre el theme base. Son
mutuamente excluyentes para que no haya ambiguedad entre "config completa" y
"override del base".
theme en Eidos nombra la familia/theme visual (base, acme,
acme-light, etc.). mode nombra el esquema efectivo light | dark y
density nombra la ergonomia visual activa. Cuando applyDom esta activo,
ActiveEidos proyecta data-theme, data-mode y data-density en el
documento a traves de ActiveDom.
mode y density se inyectan como fuentes visuales, no como dimensiones de
prefs:
const eidos = ActiveEidos.create({
theme: 'base',
modeSource: {
get: () => colorMode,
onChange: (handler) => subscribeColorMode(handler)
},
densitySource,
applyDom: true
});
Si no se pasa modeSource, ActiveEidos usa prefers-color-scheme con
fallback light. Si no se pasa densitySource, usa comfortable. No leer ni
escribir uix.prefs.theme para UIX: el modo visual pertenece a Eidos.
Un theme CSS-only puede vivir fuera de TypeScript:
[data-theme='acme-light'] {
--scale-blue-9: #006adc;
--scale-blue-a9: color-mix(in srgb, var(--scale-blue-9) 56%, transparent);
--primitive-primary-9: var(--scale-blue-9);
--primitive-primary-a9: var(--scale-blue-a9);
--color-primary-solid: var(--primitive-primary-9);
--font-family-primary: Inter, system-ui, sans-serif;
--size-md-control-height: 38px;
--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12);
}
Para pasar valores desde runtime sin recompilar:
activeEidos.setCssVariables({
'--color-primary-solid': 'rebeccapurple',
'size-md-control-height': '40px',
'shadow-3': '0 10px 28px rgb(20 20 20 / 0.16)'
});
ActiveEidos lo escribe en ${styleId}-variables como un <style>
gestionado. Por defecto valida los nombres contra getCssContract(); si una
app necesita variables locales fuera del contrato puede usar
{ strict: false }. La actualizacion es transaccional: primero renderiza y
valida el siguiente bloque, y solo reemplaza el estado runtime si el mapa es
usable. Un token desconocido no deja el style block anterior a medias.
No crear ActiveEidos desactiva la capa visual runtime de Eidos. Los
componentes Soma siguen funcionando headless porque el comportamiento pertenece
a Soma.
Qué consume
De morfo (declaración)
| Pieza | Eidos la usa para |
|---|---|
parts[].kebab |
selectores [data-{component}-{kebab}] |
parts[].archetype |
reglas transversales [data-archetype=trigger] |
parts[].states + data[].values |
variantes [data-state=open] |
parts[].data con data-starting-style / data-ending-style |
hooks de animación enter/exit |
events[].name |
selectores [data-event=dismiss], [data-event^=commit] |
events[].semantic.family + .intent |
tinta semántica de transiciones |
events[].prewrite[] (e.g. data-last-action) |
tintar exit anim por causa |
focus.trap |
hint de layout para overlays |
De Soma
Los wrappers de components/* importan las partes públicas de Soma
directamente ($soma/components/{x}) y sólo añaden superficie visual de
Eidos: tokens, recipes, layout shells y data-attrs de presentación. No hay
facade intermedio por componente. Ejemplo:
// eidos/components/toggle/types.ts
import type { ProviderProps } from '$soma/components/toggle';
export type ToggleProps = ProviderProps & { variant?: ToggleVariant; size?: ToggleSize; … };
El wrapper .svelte importa el namespace público de Soma y usa sus partes con
ese mismo namespace:
import * as Select from '$soma/components/select';
<Select.Provider {...rest}>
<Select.Trigger />
<Select.Content />
</Select.Provider>
No usar aliases como Parts, SelectBase o SomaSelectProvider. Tampoco usar
etiquetas sueltas <Provider> / <Trigger> dentro de Eidos. El wrapper añade
los data-attrs de tokens visuales (data-variant, data-size, data-block,
data-icon-only) y no reimplementa el estado.
De sema (DOM)
Sólo el DOM. El visual channel proyecta data-event-* durante el hold
mediante SignalProjector y eidos reacciona vía events.css:
[data-event-family='commit'][data-event-phase='active'] {
animation: eidos-commit-settle 260ms var(--ease-out);
}
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
}
events.css documenta los hold defaults por familia y por qué se usa
animation: @keyframes (no transition) para reacciones a señales.
Qué NO consume
- Computed state lógico del provider (e.g. la composición de
isDisabledpropio del componente con eldisabledheredado de un Field). Eidos sólo ve el resultado:[data-disabled]está o no está. - Internals de runtime. No sabe si un attr lo escribió
dom.apply, Svelte render o el provider manualmente. Sólo le importa que esté. - Layers de soma (Presence, Dismissal, ScrollLock, FocusScope). Reacciona a sus efectos visibles, no a su existencia.
La regla de los --* tokens
Eidos posee el namespace --* en el visual layer. Razones:
- Authorship clarity en debug — inspeccionar un elemento y ver
--toggle-bginforma que viene del visual layer de UIX. - Override discipline — un consumer que sobreescribe
--color-primary-elementsabe que está tocando contrato visual, no nombrando-colisionando con una variable local.
Las capas superiores (sema, soma, morfo) NO consumen estos tokens y no usan el prefijo. Cada una carga sus propias concerns (perceptual durations, behavior, contract DNA) ortogonales al rendering visual.
Theming, tokens, bundle — ver THEMING.md
La doctrina completa del sistema de theming (token scope contract, token
layers, naming conventions, bundle purge, tokens de motion, comparación
con referencias, anti-patterns, FAQ) vive en
src/uix/eidos/THEMING.md. Ese es el reference canónico.
Motion — guía en MOTION_GUIDE.md, modelo en eidos-motion.md
- Cómo animar (front door, orientado a tareas) →
MOTION_GUIDE.md: el propmotion, los 3 dominios de uso (event/state/content), el catálogo de presets, loops (spin/pulse/…), state-domain (<Card>), staggered cascade, reduced-motion, theming y debug. - Modelo y arquitectura del motor (dos momentos
--event/--state,keyframes+signatures+presets, generación de CSS, drivers JS, sets productive/expressive, typegen) →eidos-motion.md. El motor (EngineMotion) es un servicio enarts/motion(uix.motion); Eidos delega víaeidos.motiony registra ahí sus presetscss. - Decisiones e historia (incl. el motor "coordinado" retirado en el Plan A) →
MOTION_SERVICE_RFC.md.
Resumen rápido de lo que cubre, para no duplicar aquí:
| Tema | Sección en THEMING.md |
|---|---|
| Por qué el theming vive en Eidos y no en Morfo | §1.bis |
| 7 capas de tokens y override points | §3 |
| 9 roles canónicos de color | §4 |
| 6 sizes canónicos | §5 |
| Naming conventions | §6 |
| Token Scope Contract (TSC): tipo, álgebra de scope, cross-axis | §7 |
TSC v2.2: multi-part scope (parts: [...]) + cross-recipe composition |
§7 |
| Cómo añadir un componente nuevo | §8 |
| Cómo definir un theme | §9 |
| Cómo overridear tokens en runtime | §10 |
Bundle strategy + eidos:purge |
§11 |
Integración con sema vía event:* |
§13 |
| Anti-patterns y FAQ | §16, §17 |
| Cobertura universal de TSC (sin excepciones) | §18 |
Variants son canon del eidos, NO del theme (con EIDOS_VARIANTS) |
§19 |
Correcciones del engine: densidad viva + contrast on-solid (2026-06-01) |
§20 |
Eje de scaling (zoom global), separado de densidad |
§23 |
| Correcciones P2: texto on-solid por luminancia + superficies translúcidas | §24 |
| Modelo de color: paleta (31 escalas) + roles (alias) + intents (auto-derivados) | §25 |
Theme builder en runtime: eidos.applyColorScheme(seed) (motor uix.color) |
§26 |
Salida wide-gamut OKLCH default-on (hex fallback + oklch() sibling) |
§27 |
| a11y forced-colors (focus outline fallback) + ramp de bordes (slot 6→7) | §28 |
| Canon de escalas — auditoría de theming: blur · inset-shadow/ring · gradientes · breakpoints+container · opacidad · border-width · tracking (2026-06-15) | §35 |
Lo que sigue en este README son las decisiones operativas de la capa visual como módulo (typography sourcing, picker patterns, API conventions, runtime activo), independientes del sistema de theming.
Vertebración tipográfica — single source of truth en foundation
Eidos tiene dos anclas tipográficas, en capas distintas, por diseño. Esta sección documenta por qué y cómo se relacionan.
Las dos capas
src/uix/eidos/lib/primitives/typography.ts
│
├── families / sizes / weights (escala numérica)
│ ↓
│ foundation tokens
│ --font-family-{primary,secondary,display,mono}
│ --font-size-{xxs..xxxl}
│ --font-weight-{regular,medium,semibold,bold}
│ --font-line-height-{xxs..xxxl}
│
└── styles (capa semántica)
↓
named-style tokens
--style-{hero,h1..h6,body,prose,label,caption,code}-{font-family,
font-size,line-height,letter-spacing,font-weight,color}
| Capa | Quién la consume | Para qué |
|---|---|---|
Foundation numerical (--font-size-*, --font-family-primary, …) |
recipe tokens en lib/recipes/base.ts + foundation aliases (--font-ui, --leading-ui, …) |
Internals de componentes (Field labels, Combobox triggers, Button text, …) — necesitan escalado t-shirt (xs/sm/md/lg/xl) que NO mapea limpio a una semántica fija. |
Named styles (--style-label-*, --style-body-*, --style-caption-*, --style-h{1..6}-*, --style-{hero,prose,code}-*) |
typography primitives (<Text>, <Heading>, <Display>, <Code>, <Link>, …) |
API de usuario para componer contenido — el USUARIO eligió "label" o "body" y quiere que ese rol semántico se respete. |
Las dos capas no son redundantes: sirven a contextos distintos. La numérica vertebra el interior del sistema; la semántica vertebra la superficie que el consumer compone.
Cómo se vertebran sin duplicarse — la chain de aliases
Donde un valor coincide entre las dos capas, el foundation alias lee del named style, no al revés. Single source of truth: el named style.
/* generated/base.css (vía render-css.ts) */
:root {
/* Named style — fuente de verdad */
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
/* Foundation alias — vertebra los recipes */
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
}
/* recipes/base.ts → generated/base.css */
:root {
--field-label-line-height: var(--leading-ui);
--field-control-line-height: var(--leading-ui);
/* … docenas de recipe tokens más */
}
/* components/field/field.css */
[data-field-label] {
line-height: var(--field-label-line-height);
}
Cambiar STATIC_TYPOGRAPHY.styles.label.lineHeight = '1.3' (en
primitives/typography.ts) propaga a --style-label-line-height →
--leading-ui → todos los recipes → todos los componentes. Una sola
edición llega a Field, Form, Combobox, Select, Toolbar, Toast y los
typography primitives simultáneamente.
El fallback , 1.25 / , var(--font-family-primary) garantiza que el
sistema sigue produciendo CSS válido si el consumer apaga los named
styles en su foundation override.
Por qué los component recipes NO leen --style-{name}-* directamente
Tentación recurrente: "cada componente debería leer --style-label-font-size
para que sea coherente". No es la forma.
- Las t-shirts no caben en cuatro buckets. Un Field con
size="xs"tiene un label más pequeño que el "label canónico". Si su recipe leyera--style-label-font-size, perderías ese escalado o tendrías que crear--style-label-{xs,sm,md,lg,xl}-*, replicando lo que ya viven los recipe tokens. - Los matices por componente son legítimos. El message-de-Field, el
description-de-Tooltip y el subtitle-de-Card son todos "caption-ish"
pero cada uno quiere su color/weight/letter-spacing propios. Forzarlos
a un único
--style-caption-*mata expresividad. - Coupling lock-in. Día 100 el sistema quiere
label-form,label-table,label-chart. Forzar acoplamiento día 1 te lleva a replicar la jerarquía recipe en la capa semántica. - Cómo lo hacen las referencias. Radix Themes, Mantine, MUI todos
tienen escala numérica que los componentes leen; la capa semántica
existe sólo para los typography primitives (
<Text variant="body2">). Chakra v3 ofrecetextStyleacoplable pero la mayoría de sus componentes hardcodean igualmente. Acoplar todo a la capa semántica no es la práctica dominante y por buenas razones (1-3).
Auditoría: regla R-2.7
scripts/component-audit.ts detecta literales tipográficos en CSS de
componentes: font-size: 12px, line-height: 1.4, font-weight: 500,
letter-spacing: 0.02em que no estén envueltos en var(). Severidad
warn, no error — el componente sigue pasando, pero queda visible en
el report.
Escape valves (no cuentan como drift):
- valores en
var(...) - ceros e identidades:
0,0px,0em,0rem,1 - keywords:
inherit,initial,unset - comentario en línea:
font-size: 13px; /* literal: tight icon affordance */
Si necesitas un literal con justificación, anótalo. Si no, tokenizalo.
La regla "2-de-3" (heredada de morfo)
Una extensión a morfo se justifica si al menos dos de las tres capas (soma, sema, eidos) la consumen. Las que entraron por el voto de eidos:
archetype— eidos + sema (+ soma como emisor)events[].semantic.family/intent— sema + eidosevents[].prewrite[]— soma (ejecuta) + eidos (anima)data-starting-style/data-ending-style— soma (Presence) + eidos (anima)
Convenciones del API — disciplined option C (vigente 2026-05-10)
Las convenciones doctrinales viven en
src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md
sección 13 (operación instantánea = un evento; sistema
unificado de 8 tokens; intent ↔ color resolution; subset por
componente; iconOnly sr-only; sound prepare-time priming).
La convención del shape público del componente eidos vive en
components/README.md. Resumen — 7 reglas
duras:
- Un solo punto de entrada por componente — la default export es
el componente root visual, llamado igual que el componente
(
<Drawer>,<Tabs>,<Checkbox>). NO<Drawer.Provider>, NO<Drawer.Root>. - El root vive en
{name}.svelte— NO en{name}-provider.svelte. Un solo fichero por root. - NO exportar
Providerpúblicamente. La separación "Provider compound vs flat" es invención retirada; hay UNA forma compound (root + hijos atados como propiedades). - Hijos siguen el naming de air / headless:
Trigger,Content,Overlay,Title,Description,Close,Portal,Header,Footer,Item,Indicator,HiddenInput,Group,Label. No inventar nombres. Portalse incluye donde air lo tenía (Dialog, Drawer, Popover, Tooltip — overlays portaled). Importado de$soma/components/internal.- NO flat con snippet slots como API principal —
<Drawer trigger={...} title={...}>está retirado. La compound explícita expone las decisiones de composición que el flat escondía. - Hijos atados con asignación explícita, no
Object.assign(Svelte 5 puede manejar mal la mutación bulk del component constructor durante hidratación):const Drawer = DrawerRoot as DrawerNamespace; Drawer.Trigger = Trigger; Drawer.Content = Content;
Plus reglas de implementación:
- Wrapper, no fork. El
.sveltede eidos importa el namespace público de Soma (import * as Drawer from '$soma/components/drawer'), usa<Drawer.Provider>/<Drawer.Trigger>internamente y añade los data-attrs de tokens visuales. No reimplementa estado. Sizedesdelib/types.ts. Componentes que aceptan tamaños reusan el tipo compartido y narrowingan al subset que su recipe soporta (Extract<Size, 'sm' | 'md' | 'lg'>).- Sin prefijo
Eidosen los tipos. El path$uix/eidos/components/{x}ya identifica la capa. - CSS recipe sin prefijo
--eidos-. Los custom properties usan--{component}-…para los públicos y--_{component}-…para los internos.
Por qué disciplined option C
El patrón anterior (default flat con snippet slots + Provider compound duplicado) tenía dos problemas:
- Doble verdad: dos APIs (flat con
trigger/title/actionssnippets, compound con children explícitos) llevaban a la misma funcionalidad por caminos divergentes. Cualquier tweak visual exigía actualizar ambas. - Inventaba sobre air: air era pure compound (
<Drawer.Provider>con children). El flat con snippets fue una invención sin baseline ni firma del usuario.
Option C disciplinada:
- Toma de air la convención de hijos (Trigger, Content, Portal, …)
- Toma de bits-ui / shadcn-svelte la ergonomía del root con propiedades
attached (
<Drawer><Drawer.Trigger>) - Elimina la invención del flat con snippets (no era ni air ni soma)
Caso especial — Toast
Tiene dos roots independientes (no anidados):
<Toast>— manual compound (consumer iteratoaster.toasts)<Toaster />— imperative auto-mount (template default)
Export separados; Toaster NO va attached como Toast.Toaster porque
es un root competidor, no un hijo. Documentado en
components/toast/index.ts.
Defensa contra drift de selectores
La cadena morfo → soma → eidos depende de que los selectores que eidos
escribe ([data-{component}], [data-{component}-{part}],
[data-state=...], [data-event-*=...]) se mantengan en sintonía con
los attrs que el morfo declara y el runtime emite. Hay dos defensas
distintas según el tipo de consumidor:
Compile-time (TS / Svelte) — typed builder
Cualquier consumidor TypeScript que construya selectores
MUST usar semaSelector(morfo, partKebab, matchers?) desde
$uix/morfo. Esto cubre las cascade rules de sema/components/*.ts y
cualquier lógica TypeScript en eidos/components/{x}/ que apunte a
attrs morfo-backed.
import { semaSelector } from '$uix/morfo';
import { toggleMorfo } from '$uix/morfo/components/toggle';
semaSelector(toggleMorfo, 'provider', { eventName: 'commit-toggle' });
// → '[data-toggle][data-event="commit-toggle"]'
Renombrar una part o un evento en el morfo rompe el typecheck. Es
imposible que un selector TypeScript drifte silenciosamente. Ver
morfo/README.md#typed-selector-builder--semaselector.
Run-time (CSS recipes) — eidos-lint como red de seguridad opt-in
Los recipes son CSS plano (components/{x}/{x}.css); no hay typed
builder en el lado CSS. Para esa superficie:
node scripts/eidos-lint.ts toggle # un componente
node scripts/eidos-lint-all.ts # todos
Clasifica cada selector [data-*] como:
- morfo-backed — declarado en el morfo; soma runtime lo emite;
el valor (si hay enum) cae dentro de
data[].values. - eidos-only — el marker está, pero al menos un
data-*no está declarado en el morfo. Válido por convención (tokens visuales comodata-variant,data-sizevienen del wrapper). - invalid — referencia un attr declarado pero con un valor fuera del enum. Bug.
El lint es una red de seguridad, no el contrato. El contrato vive en el morfo y se defiende a nivel de tipos donde se puede. El lint existe sólo para la porción CSS-pura que aún no consume el morfo a través de TypeScript. Cuando los recipes migren a un builder, el lint podrá retirarse.
Cambios 2026-05-21 — pickers: kind + composition
kind: 'date' | 'month' | 'year'sobreDatePickeryDateRangePicker. Single source for input segments + popover view. Inputs filtran segments en elDateFieldProvider(soma) — los consumers nunca filtran en el snippet. Views como parts canónicas Eidos:<DatePicker.YearView>,<DatePicker.MonthView>, idem en range. Range views implementan state machine: empty → start, pending → end (con swap automático si reverse), complete → reset.- Footer = composición pura. Drop de
clearButton/cancelButton/ closeButtonroot props.<Picker.Footer>contiene las parts que compongas; cada part renderiza siempre que se monta (sin checks internos). Modal mode no fuerza Close visible — el consumer es responsable de incluirlo (documentado en la part). - Modal mode + footer pattern documentado en
PENDIENTES.md(norma N-6 y N-7). - Provider helpers:
commit() / cancel() / clear()en DatePickerProvider y DateRangePickerProvider.cancel()revierte al snapshot capturado en el OPEN edge víawatch(open). - Composition wins: no hay
MonthPicker/YearPicker/MonthRangePicker/YearRangePickercomo componentes propios. Esas variantes son<DatePicker kind='month'>/<DateRangePicker kind='year'>etc.
Picker patterns (contrato reutilizable)
Los pickers (date-picker, date-range-picker, y los futuros
time-picker, time-range-picker, color-picker) comparten un
contrato común. Documentado aquí como referencia canónica — cualquier
picker nuevo se construye sobre este esqueleto.
P-1 · Provider helpers: commit() / cancel() / clear()
Cada *PickerProvider expone tres métodos imperativos consumidos por
las parts del Footer:
-
commit()— cierra el popover preservandovalue.currenttal cual. Es la confirmación normal del valor seleccionado. -
cancel()— reviertevalue.currental snapshot capturado en el OPEN edge y cierra el popover. El snapshot se toma víawatch(opts.open)cuandoopentransita defalse → true:private valueOnOpen: TValue | undefined = undefined; constructor(...) { watch(() => this.opts.open.current, (isOpen) => { if (isOpen) this.valueOnOpen = $state.snapshot(this.opts.value.current); }); } cancel() { this.opts.value.current = this.valueOnOpen; this.opts.open.current = false; } -
clear()— ponevalue.current = undefined. NO cierra el popover (es una acción destructiva visible que el usuario puede querer seguir editando). Si el consumer quiere cierre tras clear, compone<Picker.Close/>en el mismo footer.
Estas tres operaciones son ortogonales: cada part Footer hace exactamente
una. No mezcles (ej. clear no debe cerrar; cancel no debe limpiar).
P-2 · mode: 'inline' | 'modal' → popover.modal
mode es un opt root del provider que se propaga al Popover.modal
underlying:
mode='inline'(default) — popover no-modal. Outside-click cierra. Escape cierra. Body scroll libre.mode='modal'— popover modal. Outside-click NO cierra (el usuario debe usar<Picker.Close/>o<Picker.Cancel/>). Escape sigue cerrando. Focus trap dentro del popover. Body scroll lock.
El consumer NO setea popover.modal directamente — eso es decisión
del provider:
<Popover.Provider modal={provider.opts.mode.current === 'modal'} ...>
Modal mode obliga al consumer a componer un Footer con <Cancel/> o
<Close/> para escapar; sin ellos el popover sólo cierra por Escape.
Sigue siendo el consumer responsable (no auto-injecta nada).
P-3 · Shell composition (Provider > Input > Portal > Content > view + Footer)
Layout canónico de un picker:
<X.Provider value={...} open={...} mode='modal' kind='date'>
{#snippet trigger()}
<X.Input /> {!-- field input con segments, button, etc. --}
{/snippet}
{#snippet content()}
<X.Content>
{#if kind === 'year'} <X.YearView />
{:else if kind === 'month'} <X.MonthView />
{:else} <X.Calendar />
{/if}
<X.Footer>
<X.Clear />
<X.Cancel />
<X.Close />
</X.Footer>
</X.Content>
{/snippet}
</X.Provider>
- Trigger snippet — entry surface (input + segments, button, swatch…).
- Content — popover content; sólo
<X.Content>puede ir aquí. - View —
Calendar,YearView,MonthView,Clock,Picker(color), etc. El consumer hace branching estructural porkindo variante (P-4 abajo). - Footer —
<X.Footer>es contenedor archetype='footer'. Las parts (Clear,Cancel,Close, etc.) se componen dentro. Cada part renderiza sin checks internos contra props del provider — presencia = visibilidad (N-7).
Si quieres omitir el footer entero, no compones <X.Footer>. Si
quieres sólo Close, compones sólo <X.Close> dentro.
P-4 · kind o equivalente como single source of truth
Cuando un picker tiene variantes de granularidad (date: day/month/year;
time: hour/minute/second), el opt kind (o equivalente) es el único
punto de configuración. Drives:
- Segments del input — filtrado a nivel del FieldProvider (soma),
no en el snippet del consumer. El provider expone una derivación
tipo
visiblePartsByKind: Set<PartName>ysegmentContentsfiltraallSegmentContent.arrcolapsando literal runs. - View del popover — el consumer hace branching estructural sobre
el opt (
{#if kind === 'year'} <YearView/>etc).
No existen componentes separados por variante (MonthPicker,
HourPicker, etc). Esas formas son <X kind='month'>,
<X kind='hour'>.
P-5 · Range state machine (para *-range-picker)
Para selección de rangos, el provider mantiene un estado interno implícito (no expuesto como opt):
- empty (
value === undefinedo{start: undefined, end: undefined}) — siguiente click seteastart. Estado pasa apending. - pending (
startdefinido,endundefined) — siguiente click seteaend. Si el nuevo punto <start, swap automático (start ↔ end). Estado pasa acomplete. - complete (
startyenddefinidos) — siguiente click reinicia: seteastart = click,end = undefined. Estado pasa apending.
Cada granularidad normaliza los endpoints:
- Year range — start = Jan 1, end = Dec 31.
- Month range — start = day 1, end = último día del mes.
- Day range — start/end son la fecha clickada tal cual.
- Hour range — start =
:00, end =:59:59.
Implementado en YearView/MonthView/Calendar (range variant). El
provider sólo expone setValue(start, end); la state machine vive
en los views.
Cambios 2026-05-21
- Tokens muted añadidos al contrato.
SurfaceColorRolesyContentColorRolesahora incluyenmuted(entreoverlay+backdropy entresecondary+disabledrespectivamente). Mapeo base:--color-surface-muted: var(--primitive-neutral-3)(light + dark) y--color-content-muted: var(--primitive-neutral-10). Antes existían 17+3 referencias rotas en recipes/components que el browser caía a initial-value (texto invisible para placeholders, separadores, weekday del calendar, group-heading del select, etc.). Solucionado víathemes/base.ts+render-css.ts+ regen. - Typo
--color-neutral-element-hovercorregido enform.css:76→--color-neutral-hover(el token correcto existente). - Raw colors removidos.
archetypes.css:122(hsl indigo fijo para[aria-selected]) yevents.css:eidos-commit-settle(rgba indigo fijo) sustituidos porcolor-mix(var(--color-primary-solid) …). Ya no quedan hex/rgb/hsl crudos ensrc/uix/eidos/**/*.cssni enrecipes/base.ts. - Cobertura de tamaños expandida. 20 componentes pasan de
sm·md·lgaxs·sm·md·lg·xl(form controls + text inputs + progress/meter + field/form) o axs·sm·md·lg(nav controls: breadcrumb, pagination, tag-group, toolbar). Categorización documentada enweb/routes/uix/lib/DEMO_AUTHORING_GUIDE.md §12.8. Paneles compuestos (calendar, date-picker, date-range-picker, file-upload, stepper, tooltip) mantienensm·md·lg. La elección responde a uso real, no a artificio: barras y controles tactiles escalan limpio en 5 escalones; paneles compuestos no se benefician por debajo desm. - Paridad de chips en demos.
field.variantytoolbar.variantdejaron de narrowarControlVarianta 2 valores; ahora exponen los 3 (surface | outline | ghost). El CSS añade selectores[data-variant='outline']con bg transparente + border visible para ambos componentes. La norma queda fijada en CHECKLIST §D-7.4. - Scrollbar portaled. Las reglas
::-webkit-scrollbar*viven sin scope enweb/routes/uix/uix.css(sólo se carga bajo/uix).--uix-linese duplica en:rootcon override:root[data-mode='dark'](atributo escrito porActiveEidos), permitiendo que portals (Combobox listbox, Popover, Dialog, Drawer) resuelvan el token aunque vivan fuera de[data-uix-docs].
Estado actual (2026-05-17)
- ActiveEidos: implementado como runtime/contexto visual y superficie de
configuracion. Gestiona primitivas, roles canonicos, themes, validacion,
contrato CSS, persistencia y render CSS (
renderStaticCss,renderThemeCss). - Authoring API:
defineEidosConfig,extendEidosConfigycreateThemeBaseEidosConfigpermiten crear configuraciones completas o extender el theme base sin mutar las constantes del sistema.getCssContract()expone el contrato estructurado,renderContractCss()lo materializa como CSS para themes externos,renderCssVariables()permite escribir overrides runtime contract-aware ytoDocument()/serialize()exponen el envelope versionado para persistencia. - Runtime CSS:
ActiveEidosreacciona a supreferencescompuesto o a fuentes explicitasmodeSource/densitySource, soporta themes de config y CSS-only viathemeSource, acepta variables runtime en un style block propio, y conapplyDom:falseno escribe en el DOM. - Wrappers por componente: la superficie migrada desde Soma ya incluye
toggle,switch,collapsible,dialog,drawer,popover,toast,accordion,avatar,tooltip,tabs,checkbox,radio-group,meter,progress,slider,pagination,rating-group,search-field,number-fieldybreadcrumb. Todos usan root visual + partes attached, sinProviderpúblico ni API flat. - Color: basado en roles canonicos de jerarquia e intents
(
primary,secondary,tertiary,neutral,affirm,fulfill,risk,threat,loss) y escalas de 12 pasos. Por cada escala genera alpha tokensa1..a12, derivadas automaticamente o sobrescribibles concolor.alphaScalespor theme. Los roles semanticos validos son solo los declarados por el contrato de Eidos. - Size:
xxs..xxlse renderiza como map global coordinado (control-height, font, icon, padding, gap, radius).fullqueda como valor de layout, no como primitiva fisica. Cada componente declara el sub-rango que su recipe mapea — la categorización canónica está enDEMO_AUTHORING_GUIDE.md §12.8(form controls + text inputs + progress/meter + field/form usanxs..xl; nav controls usanxs..lg; paneles compuestos mantienensm..lg). - Border / opacity / z-index / shadow: ya forman parte del contrato
generado. Border define escala de width/style y aliases globales; opacity
cubre estados de UI y overlays; z-index cubre capas comunes; shadow combina
escala fisica
1..6con aliases semanticos por theme. - Layout: ya forma parte del contrato generado. Incluye
containerWidth,containerPaddingInline,contentWidthyaspectRatiocomo tokens estables y authorables desdeEidosConfig. - Density: ya forma parte del contrato generado. Incluye
spaceScaleycontrolScalepara los tres niveles canonicoscompact,comfortableyspacious, conectados aldata-densityque proyectaActiveEidos. Mueve ritmo de layout y altura de controles; NO escala la tipografia. - Scaling: eje de zoom global independiente de la densidad (paridad
con el
scalingde Radix). Niveles90/95/100/105/110proyectados viadata-scaling; escalaspace,control-height,font-sizeeicon-size(SI incluye tipografia), no radius/border/ sombra. Se multiplica con la densidad. Ver THEMING.md §23. - Tipografia: usa tamaños canonicos
xxsaxxxl, familias libres por key y estilos tipograficos (h1,h2,body, etc.) comoRecord<string, TypographyStyle>. - Convencion del API de componentes: disciplined option C esta
documentada en
components/README.md. La migracion de componentes avanza por tandas pequenas y no debe arrastrar cambios de demos/rutas ni reimplementar comportamiento que pertenece a Soma. - CSS generado:
generated/base.cssya se genera desde la config base de Eidos connpm run generate:eidos-cssy se importa como foundation estatica. Los CSS historicos decontracts/ythemes/base/ya no existen en el arbol activo; el contrato se publica desdeActiveEidosy los valores base desdegenerated/base.css. Los antiguostokens/components/*tambien salen del entrypoint:EidosConfig.recipesgenera los aliases de recipe estables. - Recipes: quedan deliberadamente como
RecipeTokenSetplano. No se crea jerarquia estructurada hasta que una recipe tenga un builder/consumer real que necesite mas semantica que aliases CSS. La guardiarecipe-css-contract.test.tsevita que el theme base declare aliases no consumidos o que un CSS de componente use variables fuera del contrato. - Check del repo:
npm run checkno reporta errores ni warnings en este punto.