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

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 — SoundChannel usa la superficie DOM inyectada para listeners globales, los ciclos type-only de sema-map/types y floating/types quedan 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 hacia libs.
  • 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 — ActiveEidos gestiona 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 AudioContext se crea + resume en prepare() 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, color y presence salen del mapa perceptivo; Sema resuelve hold, sound y haptic, 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.events es el nombre canonico del motor perceptivo. En attach mode lee app.events; defineUixServices(...) declara ese servicio con el mismo nombre.
  • No existe ActiveUix.semantic como servicio publico. semantic sobrevive solo como nombre del payload en morfo.events[].semantic.
  • morfo.texts es el campo declarativo para idlangrefs owned por el componente — la nomenclatura morfo.translations se renombró a texts durante la migración 2026-05 (ver langs/components/*.ts para los catálogos por componente). langs queda para el servicio runtime.
  • prefs es el unico nombre para preferencias. ActiveUix expone el ActivePrefs bruto; Soma/Eidos consumen vistas acotadas. No se introduce settings.
  • ActiveUix.motion (EngineMotion, arts/motion) es el motor de animación, consumido por Soma (soma.motion) y Eidos (eidos.motion). Vive en arts/, no en Eidos, para que Soma anime (spring) sin dependencia soma→eidos. En attach lee app.motion.

Orden de retirada:

  1. Mantener assertContract como validador de data-contract y no como registry paralelo. registerContract queda para tooling/tests directos; Soma registra contratos via registerMorfo().
  2. 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 declara hold, base para canales reales (sound, haptic) y el set de canales activos.
  • 6 intents — neutral, affirm, fulfill, risk, threat, loss. Cada intent declara deltas per canal que se aplican sobre el base familiar cuando la familia es valenced.
  • Action verbs (SEMA_VERBS en src/uix/sema/verbs.ts) — present, dismiss, commit, cancel, announce, warn, … — son los nombres canónicos de los morfo.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 id de cada ocurrencia
  • resuelve la EffectiveSignature per 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 toca data-state, data-intent, data-disabled u otros state attrs — esos pertenecen al runtime/morfo. Eidos lee data-event-intent para reacciones a la señal transitoria y data-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 desde prefs.language.
  • uix.format — formatos regionales; consume prefs.locale como LocaleSource.
  • uix.clipboard — capacidad de escritura al portapapeles; en standalone se crea salvo clipboard:false, en attach se consume de app.clipboard cuando un componente lo pide.
  • uix.dom — unico escritor de attrs globales mediante dom.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 (Presence vía soma.motion) y Eidos (eidos.motion: genera CSS + registra sus presets). En standalone se crea con el dom disponible; en attach lee app.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 que ActiveUix conozca 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.remove que 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:

  1. Morfo no conoce código de runtime. Es declaración pura.

  2. SomaRuntime depende de Dom y Semantic. Por construcción, no por import. Provider las inyecta.

  3. Provider no escribe attrs mutables al DOM directamente. Los aporta como sources al runtime.

  4. Semantic puede usar Dom (hacia abajo). Dom no conoce Semantic.

  5. ADom no 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 leer scrollTop de su propio elemento. En cambio, listeners de document/window, consultas globales, foco imperativo y scroll de ventana pasan por ActiveDom.

  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.

  7. Lo que dom.apply escribe, Svelte no lo renderiza desde partProps. Una sola autoridad por atributo.

  8. State es la única fuente de verdad. El DOM es derivación. Los handlers mutan state; los effects derivan attrs.

  9. Event handlers en runtime.trigger son síncronos. Async va antes de llamar trigger.

  10. Guards van en el call-site, no dentro del handler. Si el guard entra al handler, ya emitió la señal perceptiva.

  11. morfo.events[].commits es descriptivo, no ejecutable. Documenta lo observable; el smoke valida.

  12. 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 Props de un componente, el morfo, los handlers de eventos. Vive en TypeScript del autor.
  • Transcripción — derivado mecánicamente de la autoría. Los Opts del provider, el wrapping readableActive(() => 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:

  • Sema convirtiéndose en runtime multimodal (regresión).
  • Soma decidiendo CSS o motion.
  • SomaRuntime interpretando lógica de negocio.
  • Eidos accediendo 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 Channel adicional en EngineSemantic sin 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

Decisiones de diseño detalladas y trade-offs históricos en el git log de la rama active-uix.

Powered by TurnKey Linux.