41 KiB
UIX — Active Architecture
Documento vivo de la arquitectura activa de UIX: motivaciones, las cuatro capas, cómo se articulan, qué problema resuelven, qué dejan deliberadamente fuera. Este doc es la visión de conjunto; los READMEs por capa son la referencia operativa.
Última revisión arquitectónica: 2026-05-15 (rama
active-uix). Cambios mayores desde la revisión anterior:
- Auditoría P0/P1 aplicada —
SoundChannelusa la superficie DOM inyectada para listeners globales, los ciclos type-only desema-map/typesyfloating/typesquedan rotos, y hay tests iniciales para los providers/motores de mayor riesgo: Dialog, Drawer, Command, Calendar, DateField, Select, Popover, Toast y RangeCalendar. Los motores de Table/Form/Command scoring salen de Soma hacialibs.- Pausa de coherencia arquitectónica — contratos mínimos por módulo auditados y fijados en
src/uix/contracts.ts: qué necesita cada capa, qué instancia si no se le pasa, cuándo degrada y cuándo debe fallar.- Provider inheritance dropped — los providers ya no heredan de un base compartido; son clases concretas y centralizan la mecanica DOM con
SomaRuntime.part(...).- Eidos entro en produccion como runtime visual aplanado —
ActiveEidosgestiona primitivas/themes, validacion, contrato CSS, persistencia e inyeccion runtime cuando la app lo pide.- Doctrina del API cerrada — convenciones 1-13 en
src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md(single-event para operaciones instantáneas, 8 color tokens, intent ↔ color resolution, subset por componente, root visual con partes attached en eidos, sound prepare-time priming, etc.).- SoundChannel prepare-time priming — el
AudioContextse crea + resume enprepare()cuando una señal sonora nace dentro del gesto de usuario, evitando side-effects globales en el constructor sin reabrir la race con la autoplay policy.- Sema sin slices visuales —
motion,colorypresencesalen del mapa perceptivo; Sema resuelvehold,soundyhaptic, y Eidos materializa la respuesta visual desde CSS/token contract.- Ownership DOM P1 cerrado — live regions, descripciones ocultas, bloqueo de seleccion de texto, observers y acciones imperativas de foco / scroll pasan por
ActiveDom.
0. Contratos mínimos por módulo
Qué requiere cada módulo, qué es opcional, cómo degrada y cuándo falla. Las
reglas de ownership y degradación se enuncian, atemporales, en
active-uix/README.md §"Reglas de ownership y
degradación".
Fuente ejecutable:
src/uix/contracts.ts. Test de frontera:src/uix/contracts.test.ts.
Modulo Requiere Opcional Si falta Error
active-uix langs,prefs,dom* clipboard,format,events,portal standalone disabledDom falta langs/dom en attach
morfo ninguno translations no registra traducciones no
soma dom events,langs,format,clipboard disabledDom desde uix morfo/event/part invalido; servicio opcional ausente
sema projector/dom* sound,haptic,visual:false ninguno SemaConfigError sin dom/projector
eidos dom* langs,format,prefs,mode/density sources applyDom:false falta dom con applyDom activo
adom surface ActiveDom target/window/breakpoints disabledDom solo explicito errores ADom sin DOM real
* `dom` significa superficie `ActiveDom`, no necesariamente DOM real. Puede
ser `disabledDom` solo en standalone cuando el integrador pide `dom:false`.
En attach debe venir de `ActiveApp`.
* Fuera de `ActiveUix`, `EngineSemantic` con canal visual activo debe recibir
`dom` o `projector`; `visual:false` es la degradacion explicita.
Los siguientes cambios deben derivar de esta tabla, no de constructores inventados desde capas inferiores.
Correccion aplicada: ActiveUix no importa ni instancia Soma/Eidos.
portal queda como setting generico de UIX; Soma lo consume como default
para portalTo, y ActiveEidos.create(...) crea el scope visual cuando la
app necesita Eidos.
0.1 Naming canonico
La arquitectura puede mantener los nombres historicos de carpetas
(morfo, soma, sema, eidos), pero la superficie publica debe usar una
gramatica consistente. Regla general: un nombre representa un unico concepto;
si un termino es alias historico, debe quedar marcado y con ruta de retirada.
| Concepto | Nombre canonico | Evitar / retirar |
|---|---|---|
| Servicio runtime de traducciones | langs |
lang como servicio |
| Idioma activo | prefs.language |
locale para idioma de traducciones |
| Locale / formatos regionales | prefs.locale |
language para formatos |
| Catalogos declarativos de texto | translations |
langs dentro de morfo; tablas globales por componente |
| Preferencias UIX | prefs |
settings, presentation como nombre nuevo |
| Eventos perceptivos UIX | events |
semantic como servicio publico |
| Ocurrencia en vuelo | signal |
usarlo para la capa completa |
| Payload semantico de un evento morfo | semantic |
mezclarlo con el servicio runtime |
| Contrato declarativo TS | morfo |
contract como API TS duplicada |
| Contrato CSS/data exportado | contract |
morfo para CSS externo |
| Bridge CSS runtime | ActiveEidos |
runtime visual obligatorio de componentes |
| Engine puro independiente | EngineX solo si vive fuera de ActiveX |
engines decorativos |
| Root visual de Eidos | DrawerProps, DialogProps |
DrawerProviderProps en API visual |
Decisiones ya aplicadas:
ActiveUix.eventses el nombre canonico del motor perceptivo. En attach mode leeapp.events;defineUixServices(...)declara ese servicio con el mismo nombre.- No existe
ActiveUix.semanticcomo servicio publico.semanticsobrevive solo como nombre del payload enmorfo.events[].semantic. morfo.textses el campo declarativo para idlangrefs owned por el componente — la nomenclaturamorfo.translationsse renombró atextsdurante la migración 2026-05 (verlangs/components/*.tspara los catálogos por componente).langsqueda para el servicio runtime.prefses el unico nombre para preferencias.ActiveUixexpone elActivePrefsbruto; Soma/Eidos consumen vistas acotadas. No se introducesettings.ActiveUix.motion(EngineMotion,arts/motion) es el motor de animación, consumido por Soma (soma.motion) y Eidos (eidos.motion). Vive enarts/, no en Eidos, para que Soma anime (spring) sin dependencia soma→eidos. En attach leeapp.motion.
Orden de retirada:
- Mantener
assertContractcomo validador de data-contract y no como registry paralelo.registerContractqueda para tooling/tests directos; Soma registra contratos viaregisterMorfo(). - Recién despues limpiar nombres de props en componentes visuales.
1. La tesis en una frase
UIX trata un componente como cuatro capas con contratos explícitos, no como un bloque monolítico que mezcla estructura, comportamiento, semántica y presentación.
Las cuatro capas son Morfo · Soma · Sema · Eidos. Cada una hace un trabajo nítido y se comunica con las otras únicamente a través del DOM y de un contrato declarativo compartido. Ninguna invade a la siguiente.
2. El problema que resuelve
En la mayoría de frameworks de UI, un componente acumula:
- el contrato público del DOM (atributos, parts, ARIA)
- el comportamiento headless (estado, teclado, foco, eventos)
- la semántica del evento (qué significa "abrir un dialog" más allá del cambio de un atributo)
- la capa visual (CSS, animaciones, theming)
- los motores modales (sonido, haptic y reacciones CSS vía eventos DOM)
- la integración con servicios de app (i18n, dates, theme, etc.)
Todo eso vive mezclado. Renombrar una part toca seis sitios sin verificación
automática. La semántica de un evento se entierra en strings hardcodeados que
solo el componente conoce. El CSS se acopla a estructura DOM incidental. Los
motores de sonido reescriben mapeos por componente. Cuando quieres cambiar
una decisión transversal — "todos los triggers deben tener un hover dim
común" — tienes que enumerar los 30 componentes que tienen un trigger.
UIX rompe ese bloque en cuatro capas con responsabilidades disjuntas y un canal de comunicación común: el DOM con atributos declarados por el contrato cross-layer.
3. Las cuatro capas
Morfo — el contrato cross-layer
Morfo declara la genética del componente: sus partes, los data-* que
emite, los ARIA que aporta, los roles, los estados, los eventos semánticos
que puede disparar, y las teclas que dispatcha. Una declaración por
componente, en TypeScript, validada por sium.
Morfo no ejecuta nada. Es DNA, no proteína.
// src/uix/morfo/components/dialog.ts (extracto)
export const dialogMorfo = {
name: 'Dialog',
kebab: 'dialog',
scope: ['soma', 'sema'],
events: [{
name: 'close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
prewrite: [{ part: v.partRef('content'),
attr: 'data-last-action', value: 'cancelled' }],
commits: { part: v.partRef('content'),
attr: 'data-state', value: 'closed' }
}],
parts: [
{ name: 'Trigger', kebab: 'trigger', archetype: 'trigger', role: 'button', ... },
{ name: 'Content', kebab: 'content', archetype: 'content', role: 'dialog', ... },
// ...
]
} as const satisfies Morfo
Morfo es el único punto de articulación cross-layer. Cualquier dato que las otras capas necesitan compartir entre sí pasa por aquí. Es la regla estructural más importante: si dos capas necesitan saber lo mismo, ese "lo mismo" vive en morfo.
Soma — el comportamiento headless
Soma consume morfo y lo transcribe a comportamiento ejecutable. Lee
morfo.events, morfo.keyboard, morfo.parts[].data y aria, y los
materializa: dispatcha teclas, aplica atributos al DOM, gestiona estado,
integra con context (Field, Form, Soma).
Soma no decide visualidad. No sabe colores. No sabe transiciones. No sabe sonidos. Solo sabe estados, eventos, foco, teclado y cómo materializar todo eso en el DOM.
La pieza central de soma es SomaRuntime: un intérprete del morfo que
recibe del provider las fuentes reactivas (states, props, parts, events,
actions) y se encarga de:
- emitir los attrs estáticos (
partProps) - aplicar los attrs derivados de state via
dom.apply(effects) - dispatchear teclas via
keydown(part, event) - ejecutar eventos via
trigger(eventName)con la cadena perceptiva completa
El provider aporta lo que morfo no puede inferir: getters reactivos sobre el estado interno, handlers concretos, glue de layers ortogonales (Presence, Dismissal, ScrollLock).
Sema — vocabulario + canales perceptivos
Sema define el vocabulario canónico del framework y orquesta el
dispatch de señales perceptivas a un conjunto de canales modulares.
Vocabulario canónico (SEMA_MAP en src/uix/sema/sema-map.ts):
- 8 familias —
contact,commit,signal,handle,emerge,shift,sustain,delegate. Cada una declarahold, base para canales reales (sound,haptic) y el set de canales activos. - 6 intents —
neutral,affirm,fulfill,risk,threat,loss. Cada intent declaradeltasper canal que se aplican sobre el base familiar cuando la familia es valenced. - Action verbs (
SEMA_VERBSensrc/uix/sema/verbs.ts) —present,dismiss,commit,cancel,announce,warn, … — son los nombres canónicos de losmorfo.events[].name.
Sema no decide qué evento ocurrió — eso lo decide el provider. El
EngineSemantic solo:
- mantiene un registry de canales que implementan
Channel - genera el
idde cada ocurrencia - resuelve la
EffectiveSignatureper canal (base × intent deltas) - despacha cada signal a todos los canales registrados
- bloquea al caller solo el tiempo que el canal visual necesite
src/uix/sema/
├── engine.ts registry + channel prepare/dispatch
├── resolver.ts resolveSignature(signal): EffectiveSignature
├── sema-map.ts tabla per-family base + per-intent deltas (typed)
├── verbs.ts SEMA_VERBS catalog
└── chans/
├── types.ts interfaz Channel
├── visual.ts VisualChannel (built-in, data-event projection + hold)
├── sound.ts SoundChannel (Web Audio, prepare-time priming)
└── haptic.ts HapticChannel
El canal visual (built-in) es el único que comparte plano DOM con el
commit estructural posterior, y por tanto el único que bloquea al caller.
EngineSemantic ejecuta hooks genéricos de canales; el
VisualChannel.prepare() proyecta data-event + data-event-id +
data-event-phase (y opcionalmente data-event-family y
data-event-intent) al target mediante un SignalProjector. En
ActiveUix ese proyector recibe uix.dom, por lo que la escritura de attrs
entra por el mismo dueño DOM que usa soma. VisualChannel mantiene el
hold configurable y el cleanup retira la proyección antes de resolver la
Promise (semántica secuencial estricta).
Namespace discipline: la proyección semántica escribe solo atributos bajo el prefijo
data-event-*. Nunca tocadata-state,data-intent,data-disabledu otros state attrs — esos pertenecen al runtime/morfo. Eidos leedata-event-intentpara reacciones a la señal transitoria ydata-intent(cuando lo emita el morfo) para el estado persistente.
Los hold defaults internos al canal visual vienen de
SEMA_MAP.families[*].hold y se resuelven sobre la escala perceptiva
SEMA_DURATIONS: glimpse, brief, noticed, etc. El integrador puede
sobreescribir per signal (signal.hold) o globalmente vía
new EngineSemantic({ dom, visual: { defaultHold } }).
El SoundChannel está implementado: sintetiza earcons cortos vía
Web Audio (dos osciladores → biquad lowpass → envelope ADSR-lite,
parametrizado por effective.sound.{pitch, centroid, gain, contour, roughness, duration}). Hace prepare-time priming: crea + resume el
AudioContext en el prepare() de una señal sonora, síncronamente dentro
del gesto de usuario. Solo despues registra el listener capture-phase en
document para re-resume posteriores. Es opt-in:
new EngineSemantic({ sound: true }).
Las firmas sonoras reutilizables viven en src/uix/sema/sounds.ts.
Los packs de componente referencian nombres (sound('handle.pickup.air'),
sound('notification.ping')) o recetas dinámicas, no constantes sueltas.
Una entrada del repositorio puede ser sintética o un .wav externo con
fallback sintético; SoundChannel reproduce sampleUrl y cae a síntesis si
fetch/decode falla.
El HapticChannel sigue como canal opt-in; el registry ya lo acepta para que cuando se implemente encaje sin cambios estructurales. Cualquier canal no-visual es fire-and-forget: gestiona su propio timing en su plano sin afectar al caller.
Eidos — la capa visual
Eidos es la capa visual. Su acceso al sistema es el DOM: lee
parts, data-attrs, ARIA, archetypes y event signals que las otras
capas escriben. No importa internals de soma; no pregunta a sema.
Eidos no es solo CSS. Cubre lo que en la rama muerta air/ era el
"runtime visual" más el sistema de tokens — sin heredar código. Su
estructura actual:
src/uix/eidos/
├── active-eidos.svelte.ts ActiveEidos: runtime/contexto visual creado por ActiveEidos.create
├── archetypes.css reglas comunes a [data-archetype=*]
├── events.css reacciones a [data-event-*] (sema visual)
├── generated/base.css foundation CSS generado desde EidosConfig base
├── themes/fonts.css font faces usados por el base generado
├── lib/ soporte de config, recipes, contrato CSS y tipos compartidos
└── components/{x}/ recipe + wrapper Svelte + tipos por componente
├── {x}.css recipe (selectores [data-{x}], variants)
├── {x}.svelte wrapper sobre el provider headless de soma
├── types.ts Props visuales + props publicas de soma
└── index.ts default root + partes attached
ActiveEidos es la fuente de verdad nueva del theming: primitivas
(color + alpha scales, size map, espacios, control height, radius, borde,
opacidad, z-index, focus ring, layout, tipografia, shadow, motion, icon), roles
semanticos y themes. Tambien resuelve el theme activo desde sus fuentes
visuales (theme, modeSource, densitySource o defaults) e inyecta CSS runtime
cuando la app no lo precompila. Los themes externos pueden venir solo por CSS
si respetan el contrato de custom properties (themeSource: 'auto' | 'config' | 'css');
getCssContract() publica ese contrato como datos typed y
renderContractCss() lo materializa como CSS vacío desde la config.
Los aliases de recipe por componente (--toast-*, --dialog-*, etc.) viven en
EidosConfig.recipes y se generan dentro de generated/base.css; las recipes
CSS permanecen como selectores/estados, no como fuente paralela de tokens.
ActiveEidos.listRecipes() y getRecipeTokens(component) son la superficie
de consulta para editores de theme; devuelven nombres y copias defensivas,
no handles mutables al config interno.
ActiveEidos tambien puede escribir variables runtime en un style block
propio, validandolas contra el contrato para que un editor de theme no tenga
que mutar CSS a mano variable por variable. La persistencia de configuracion
completa usa EidosConfigDocument (kind + version + options), de modo que
EidosConfig queda como objeto puro de authoring y el versionado vive
en el borde de almacenamiento/intercambio.
ActiveEidos es tambien el contexto que consumen los wrappers Svelte:
ActiveEidos.require() expone solo la superficie visual (dom,
langs, format, prefs y helpers como resolve(...), breakpoint(...) e
isBelow(...)). Los wrappers no importan getActiveUix() directamente.
El wrapper Svelte publico sigue la opcion C disciplinada: un root visual
<Drawer> / <Tabs> / <Checkbox> y partes attached
<Drawer.Trigger>, <Drawer.Content>, etc. No hay Provider publico y
no hay API flat con snippets como forma principal.
Reglas de selección (eidos lee, no escribe):
/* Estilo común a todos los triggers, independiente del componente */
[data-archetype='trigger'] {
cursor: pointer;
}
/* Tinta exit anim según la causa (saved/cancelled/dismissed) */
[data-state='closed'][data-last-action='cancelled'] {
animation: ...;
}
/* Reacción a una señal perceptiva durante el hold (200–260ms según familia) */
[data-event-family='commit'][data-event-phase='active'] {
animation: eidos-commit-settle 260ms var(--ease-out);
}
/* Variante por intent transitorio (de la señal, no del estado) */
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
}
El DOM es el canal entre events/sema y eidos. El VisualChannel proyecta la
ocurrencia mediante SignalProjector + ActiveDom; Eidos reacciona.
3.bis ActiveUix sin frontend (cerrado)
frontend ya no existe como artefacto activo. La fuente transversal de
preferencias es ActivePrefs, siguiendo el mismo patron que usa ActiveApp;
la proyeccion DOM es explicita y vive fuera de ActiveUix.
La particion actual:
uix.langs— idioma y traducciones; se sincroniza desdeprefs.language.uix.format— formatos regionales; consumeprefs.localecomoLocaleSource.uix.clipboard— capacidad de escritura al portapapeles; en standalone se crea salvoclipboard:false, en attach se consume deapp.clipboardcuando un componente lo pide.uix.dom— unico escritor de attrs globales mediantedom.apply.uix.motion— motor de animación (EngineMotion,arts/motion): registra + corre presets del momento--state(CSS settle / drivers JS spring/waapi/rect). Lo consumen Soma (Presencevíasoma.motion) y Eidos (eidos.motion: genera CSS + registra sus presets). En standalone se crea con eldomdisponible; en attach leeapp.motion.uix.prefs— preferencias efectivas transversales:language,locale,direction,motion,sound,haptic, etc.uix.portal— target generico de portales; capas como Soma lo adaptan a su API (portalTo) sin queActiveUixconozca esas capas.
Eidos queda fuera de la superficie de ActiveUix: ActiveEidos.create(...)
crea el contexto visual y, si se necesita CSS runtime, usa uix.dom,
uix.langs, uix.format y fuentes explicitas de mode/density cuando el
integrador no quiera los defaults.
prefs.direction es la unica fuente de direccion efectiva. Si el usuario no
ha fijado intent, deriva desde prefs.language; si llama a
prefs.direction.set('rtl'), ese override manda; si llama a
prefs.direction.clear(), vuelve a derivar. El atributo html[dir] es solo
la proyeccion DOM de ese valor efectivo.
La proyeccion DOM queda separada por ownership:
ActivePrefsDomProjection -> dir, data-motion, data-sound, data-haptic
ActiveEidos -> data-theme, data-mode, data-density
En modo standalone, createActiveUix() instancia ActivePrefs con el preset
estandar de UIX y crea los servicios configurados. En modo attach,
attachActiveUix(app) reutiliza app.prefs porque prefs pertenece al core
de ActiveApp. langs y dom son los servicios requeridos para attach:
si faltan, attachActiveUix(app) falla temprano. clipboard, events y
format son opcionales; si una capa los necesita y no fueron declarados en la
app, el getter de ActiveUix falla explicitamente.
ActiveUix no auto-proyecta preferencias al DOM. La proyeccion cross-modal
existe en arts/prefs como createActivePrefsDomProjection(...); la cablea
el composition root que quiera esos atributos globales. Esto permite usar
ActiveApp sin UIX, UIX sin Eidos, o Eidos con CSS precompilado sin crear
proyectores duplicados.
Cuando una shell UIX quiere modo visual runtime, el flujo canonico es:
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
const eidos = ActiveEidos.create({
theme: 'base',
modeSource,
applyDom: true
});
prefsProjection y eidos se disponen con la shell. El modo claro/oscuro no
se escribe en prefs.theme; se pasa a ActiveEidos como fuente visual.
4. Cómo se articulan — la cadena de transcripción
Las cuatro capas forman una cadena de transcripción declarativa donde cada una traduce el contrato de la anterior a su lenguaje:
Morfo declara (TypeScript constant + sium schema)
↓
SomaRuntime transcribe (Soma — leyendo morfo + sources)
↓
Provider aporta sources/handlers (Soma — TypeScript class)
↓
Effects sincronizan attrs (Soma — $effect + dom.apply)
↓
EngineSemantic despacha señales (Sema — registry + prepare/dispatch)
↓
VisualChannel prepara data-event* (Sema — via SignalProjector/uix.dom)
↓
Eidos lee el DOM y aplica CSS (Eidos — selectores + tokens)
Más, en paralelo (no en cadena):
- ADom materializa los
dom.apply/dom.removeque Soma le pide para los attrs estructurales derivados (data-state, aria-*, etc.). - Canales no visuales de Sema (sound, haptic, futuros) reciben la misma señal y la materializan en su modalidad — fire-and-forget.
Las piezas con responsabilidades disjuntas:
| Pieza | Responsabilidad | No hace |
|---|---|---|
| Morfo | Declarar el contrato | Ejecutar nada |
| SomaRuntime | Transcribir morfo a comportamiento | Decidir lógica de negocio |
| Provider | Aportar fuentes reactivas + handlers | Escribir attrs mutables al DOM |
| Effects | Aplicar attrs derivados via dom.apply |
Decidir qué attrs (eso lo dice morfo) |
| EngineSemantic | Registry de canales + prepare/dispatch | Conocer DOM, audio, vibración |
| VisualChannel | Proyectar data-event* via projector + hold awaited |
Escribir attrs estructurales |
| SignalProjector | Proyectar data-event* via dom.apply |
Decidir cuándo emitir |
| ADom | Mutaciones DOM imperativas para attrs estructurales | Conocer las capas superiores |
Eidos queda fuera de esa cadena: lee del DOM, no participa en la
transcripción.
5. La cadena causal de una interacción
Ejemplo concreto: el usuario clickea el botón × de un Toast.
1. Browser dispara click → Svelte llama Close.onclick
2. Close.onclick ejecuta:
void this.toastItem.runtime.trigger('dismiss')
3. SomaRuntime.trigger('dismiss'):
3.1. Busca event 'dismiss' en morfo.events ✓
3.2. Resuelve target = Item DOM element via partRef('item')
3.3. AWAIT events.emit({ target, name: 'dismiss', family: 'emerge' })
EngineSemantic despacha la señal a TODOS los canales registrados:
- VisualChannel.prepare(): SignalProjector aplica data-event*
via dom.apply(target, data-event-family=emerge)
- VisualChannel.handle(): mantiene el hold (240ms para emerge)
- cleanup: dom.apply(target, data-event*=undefined)
- SoundChannel, HapticChannel: fire-and-forget (no awaited)
La Promise resuelve cuando el VisualChannel ha terminado el cleanup
(semántica secuencial estricta)
4. SomaRuntime invoca el handler del provider:
sources.events.dismiss() →
this.provider.toaster.dismiss(opts.toast.current.id) →
toast.dismissing = true (state mutation)
5. EFFECTS reactivos del runtime ven que isOpen cambió:
resolvePartAttrs recomputa los attrs del item part
dom.apply(target, { 'data-state': 'closed' }) en el siguiente tick
6. Eidos (CSS) ha reaccionado durante toda la secuencia:
- durante t=0..240ms: [data-event^="dismiss"] dispara animation @keyframes
fade-out (CSS animation, no transition: corre full-duration aunque el
attr desaparezca después)
- en t≈245ms: [data-state="closed"] toma el relevo
- Presence layer aplica data-ending-style; CSS termina la animación
State es la única fuente de verdad. El DOM es derivación. La señal perceptiva PRECEDE al cambio estructural por el hold completo (~240ms para emerge) — el caller espera el cleanup antes de mutar estado, dándole a CSS una ventana perceptible para coreografiar la salida.
6. Las primitivas que pasan entre capas
Atributos DOM — el canal universal
Todo lo que pasa entre capas pasa por atributos DOM:
| Atributo | Quién escribe | Quién lee |
|---|---|---|
data-{component} |
partProps (estático) | Eidos (selector raíz) |
data-{component}-{part} |
partProps (estático) | Eidos (selector parte) |
data-archetype="trigger" |
partProps (estático) | Eidos (selector transversal) |
id |
partProps | ARIA refs, tests |
role |
dom.apply (effect) | Lectores de pantalla, Eidos |
aria-* |
dom.apply (effect) | Lectores de pantalla, Eidos |
data-state="open" |
dom.apply (effect) | Eidos (selector variant) |
data-disabled |
dom.apply (effect) | Eidos (selector estado) |
data-event="dismiss" |
sema.emit (transient) | Eidos (selector evento) |
data-event-phase="active" |
sema.emit (transient) | Eidos |
data-event-id="sig-N" |
sema.emit (transient) | Sound/Haptic futuros |
data-event-family="commit" |
sema.emit (transient) | Eidos (selector familia) |
data-event-intent="risk" |
sema.emit (transient) | Eidos (tinta de la señal) |
data-color="primary" |
dom.apply (effect) | Eidos (recipe per token) |
data-intent="risk" |
dom.apply (effect, opcional per morfo) | Eidos (estado persistente) |
data-last-action="cancelled" |
trigger prewrite | Eidos (tinta exit) |
data-starting-style / data-ending-style |
Presence layer | Eidos (animation hooks) |
Regla operativa: lo que dom.apply escribe, Svelte no lo renderiza. La
identidad estática (id + marker + archetype + ref attachment) ship via
partProps. Lo derivado de state ship via dom.apply desde effects. No
hay double-write.
Vocabularies cross-layer
Canonical: the semantic vocabulary (families, intents, verbs) lives in
docs/CANON.md. The summary below is for the cross-layer view; the canon + code are authoritative.
Dos vocabulary estables anclan la articulación:
Archetypes (src/uix/morfo/types.ts:ARCHETYPE_VOCABULARY):
provider · trigger · content · overlay · viewport
item · option · indicator · thumb · track
label · title · description · close · action
header · image · fallback · arrow · separator
group · input · segment · preview
24 categorías de parte que aparecen en múltiples componentes. Una Trigger
de Dialog, Popover, DropdownMenu y Tooltip son la misma categoría — Eidos
las puede estilar transversalmente con [data-archetype=trigger].
Verbs (src/uix/sema/verbs.ts:SEMA_VERBS), agrupados por familia:
contact: press · tap · activate · focus · trigger · release
commit: select · unselect · toggle · save · submit · confirm · complete ·
fail · cancel · reset · discard · delete · restore · expire ·
acknowledge · apply · partial · block · move · set · remove ·
reorder · upload
signal: announce · notify · warn · alert · inform · emphasize · remind
handle: pick · carry · drop · drag · resize · reorder · rotate · scroll · zoom
emerge: present · dismiss · open · close · expand · collapse · reveal · hide
shift: enter-mode · exit-mode · navigate · route · step · return · context
sustain: start · progress · loading · waiting · syncing · processing ·
streaming · pending · retrying · upload · end
delegate: offer · plan · authorize · act · review · escalate · return
Verbs que parecen de una familia pero son de otra per la canon:
select / toggle / acknowledge son commit (fijan estado, no son
solo contacto); edit es shift.enter-mode (cambia régimen).
morfo.events[].name debería alinear con este vocabulario en una de
dos formas: {verb}-{variant} (dismiss-outside, close-cancel) o
{family}-{verb} (commit-toggle, commit-save). Permite a Sema/
Sound/Haptic/Eidos suscribir o estilar por verb o por familia sin
enumerar componentes. validateEventName reconoce ambas formas.
Intents (src/uix/sema/sema-map.ts:SEMA_MAP.intents), 6 valores:
neutral — sin carga afectiva (default)
affirm — positivo bajo ("todo va bien")
fulfill — positivo resolutivo ("objetivo cumplido")
risk — negativo moderado ("revisa esto")
threat — negativo activo ("alarma, atención inmediata")
loss — consecuencia consumada (negativo + baja activación, posterior)
Intent es ortogonal a familia: una commit puede ser affirm (subscribe),
risk (publish), threat (delete), o neutral (toggle plano). El
provider lo declara en morfo.events[].semantic.intent (literal) o lo
expone como prop (fromProp + supported subset).
7. Reglas duras
Las invariantes operativas que mantienen el sistema coherente:
-
Morfo no conoce código de runtime. Es declaración pura.
-
SomaRuntime depende de Dom y Semantic. Por construcción, no por import. Provider las inyecta.
-
Provider no escribe attrs mutables al DOM directamente. Los aporta como sources al runtime.
-
Semanticpuede usarDom(hacia abajo).Domno conoceSemantic. -
ADomno conoce capas superiores. Solo aplica mutaciones, listeners y acciones DOM transversales que recibe.La frontera DOM no exige envolver lecturas locales: un componente puede llamar
el.contains(...),el.closest(...),el.getBoundingClientRect()o leerscrollTopde su propio elemento. En cambio, listeners dedocument/window, consultas globales, foco imperativo y scroll de ventana pasan porActiveDom. -
Eidosconsume DOM ydata-*, no internals de Soma ni Sema. Si lo necesita, debe estar declarado en morfo o emitido en una señal de sema. -
Lo que
dom.applyescribe, Svelte no lo renderiza desdepartProps. Una sola autoridad por atributo. -
State es la única fuente de verdad. El DOM es derivación. Los handlers mutan state; los effects derivan attrs.
-
Event handlers en
runtime.triggerson síncronos. Async va antes de llamartrigger. -
Guards van en el call-site, no dentro del handler. Si el guard entra al handler, ya emitió la señal perceptiva.
-
morfo.events[].commitses descriptivo, no ejecutable. Documenta lo observable; el smoke valida. -
La regla 2-de-3 para extender Morfo. Una extensión a morfo solo se justifica si al menos dos de las tres capas (soma, sema, eidos) la consumen. Soma-only conveniences viven en provider via virtual prop.
8. La distinción autoría / transcripción
Una lente útil para decidir dónde vive cada cosa:
- Autoría — escrito una vez por un humano, con intención. Los
Propsde un componente, el morfo, los handlers de eventos. Vive en TypeScript del autor. - Transcripción — derivado mecánicamente de la autoría. Los
Optsdel provider, el wrappingreadableActive(() => x)por prop, los attrs estructurales. Lo deriva una helper / runtime / generator.
UIX intenta que solo la autoría sea humana. La transcripción es código que escribe código:
| Autoría | Transcripción | Cómo |
|---|---|---|
Props |
Opts |
OptsFromProps<P, Managed, State> |
| Cada prop de wrapper | Active/State boxes | bindProps({ ... }) |
morfo.events[].commits |
DOM final tras handler | Effects derivan |
morfo.parts[].data |
Atributos en cada tick | Resolver + dom.apply |
morfo.events[].name + verb canonical |
data-event="..." |
sema.emit |
Esta distinción explica por qué la regla 2-de-3 se mantiene: el morfo es autoral cross-layer. Si solo soma necesita algo, es transcripción soma-internal — no autoral, no merece estar en morfo.
9. Lo que NO es esta arquitectura
Para evitar mission creep, conviene fijar lo que UIX no quiere ser:
- No es una colección visual. Eidos será visual; UIX como sistema no.
- No es un wrapper opinionated sobre primitives existentes. Las cuatro capas son originales, no envuelven Radix/Headless UI.
- No es un design system clásico. Tokens, themes y recipes pertenecen a Eidos, no al núcleo.
- No es un servicio monolitico de eventos que ejecuta todas las
modalidades. Sound, Haptic, Motion y futuras modalidades se registran
como canales del
EngineSemantic; cada uno gestiona su propia modalidad. El engine es solo registry + dispatch. - No es un EventEmitter global disfrazado de arquitectura. Cada evento tiene un target específico en el DOM y un dueño semántico declarado en morfo.
- No es un mini-DSL en JSON. Morfo es declarativo descriptivo, no programa. La lógica vive en TypeScript del provider; morfo solo dice qué attrs y qué semántica.
10. Estado del proyecto
Los snapshots de estado fechados ("qué hay implementado a fecha X") viven en
docs/process/— p. ej.active-architecture-snapshot-2026-05.md. Este documento describe la arquitectura, no el avance.
11. Riesgos reconocidos
Ningún diseño está libre de riesgos. UIX tiene cuatro de manera explícita:
11.1 Exceso de capas
Si las fronteras no se mantienen nítidas, el sistema se siente más complejo de lo que resuelve. La regla 2-de-3 y la doctrina de "virtual prop" mitigan, pero requieren disciplina sostenida.
11.2 Nombres sin disciplina
Morfo, Sema, Soma, Eidos son nombres que solo funcionan si los
contratos son nítidos. Si Sema empieza a saber de DOM, o Soma decide
visualidad, los nombres se vuelven decoración.
11.3 Invasión de responsabilidades
El peligro constante es que una capa intente hacer el trabajo de otra:
Semaconvirtiéndose en runtime multimodal (regresión).Somadecidiendo CSS o motion.SomaRuntimeinterpretando lógica de negocio.Eidosaccediendo a internals de soma.
UIX solo funciona si cada capa acepta sus límites.
11.4 Falta de precedentes
No hay sistemas de UI con esta composición exacta. Eso significa más libertad arquitectónica pero también menos patrones externos que copiar cuando aparece un caso límite.
12. Por qué puede valer la pena
Si las fronteras se mantienen, UIX ofrece algo poco común:
- Explicabilidad arquitectónica. Cada decisión cae en una capa reconocible; el "dónde vive esto" tiene una respuesta predecible.
- Menos drift entre capas. El morfo es autoritativo; las demás capas derivan. Renombrar una part toca un sitio, no seis.
- Validación automática del contrato. Sium schema + smoke + morfo-check detectan drift estructural antes de que llegue a producción.
- Más libertad para introducir engines nuevos. Sound, Haptic, Motion,
cualquier modalidad futura se registra como un
Channeladicional enEngineSemanticsin tocar morfo ni soma. - Honestidad sobre fronteras de framework vs integrador. UIX provee vocabularios, contratos, transporte y puntos de extensión; no finge decidir por todas las modalidades de todas las apps.
La idea importante:
La coherencia cross-modal puede tratarse como responsabilidad del integrador, no como una falsa promesa de un runtime centralizado que pretende saberlo todo.
13. La frase resumen
Morfo declara · SomaRuntime transcribe · Provider aporta · Effects sincronizan · Semantic emite · Dom aplica · Eidos lee.
Siete palabras que describen la cadena entera. Si una decisión arquitectónica contradice una de esas siete, la decisión está mal — o la arquitectura tiene que evolucionar conscientemente.
14. Para profundizar
- src/uix/README.md — posicionamiento general (más narrativo)
- src/uix/morfo/README.md — declaración, archetypes, regla 2-de-3
- src/uix/sema/README.md —
emitcontract, verbs canónicos - src/uix/soma/SOMA_ARCHITECTURE.md — runtime + componentes
- src/uix/soma/COMPONENT_GUIDE.md — guía operativa para crear / migrar componentes
- src/uix/eidos/README.md — capa visual: tokens, themes, recipes, wrappers
src/arts/adom/README.md—dom.apply+ servicios DOM reactivossrc/docs/GUIA_IMPLEMENTACION_SEMAUIX.md— convenciones doctrinales del API
Decisiones de diseño detalladas y trade-offs históricos en el git log de la
rama active-uix.