# Sema `Sema` define el dominio semántico canónico de UIX y orquesta la emisión de señales perceptivas. ## Qué es - familias canónicas: `contact`, `commit`, `signal`, `handle`, `emerge`, `shift`, `sustain` - intents canónicos: `neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss` - política de intent por familia (`SEMA_FAMILY_POLICY`): determina cuándo el intent es obligatorio (`'expected'`), opcional (`'allowed'` / `'optional'`) - normalización entre shape estructurado y label canónico - validación mínima del dominio - `EngineSemantic` como registry de canales + dispatch de ocurrencias `Sema` no decide qué evento ocurrió. El provider lo decide. `EngineSemantic` recibe la ocurrencia y la despacha a los canales perceptivos registrados. Cada canal materializa la señal en su modalidad (DOM, audio, vibración). ## Handoff 2026-05-13 Sema ya tiene contrato explicito de DOM: si el canal visual esta activo, `EngineSemantic` debe recibir `dom` o `projector`. Si se construye desde `ActiveUix`, recibe el `dom` de `ActiveUix`; si se usa directamente fuera de UIX, el integrador debe pasar un writer explicito o usar `visual:false`. Sema no cae a escrituras DOM directas por defecto. Tambien queda por decidir si `emit` debe seguir siendo secuencial estricto de forma global o si la secuenciacion pertenece al evento/morfo. No cambiar esto sin documentar antes la tabla de contratos minimos en [`../active_architecture.md`](../active_architecture.md). ## Qué ya no es `Sema` ya no es un runtime multimodal monolítico. El `EngineSemantic` no contiene: - política global de accesibilidad - mapa perceptivo cross-canal - decisiones sobre qué efecto modal aplicar Eso vive en cada canal por separado: - `EngineSemantic` mantiene un registry de canales y despacha cada signal - el proyector DOM materializa la señal como `data-event*` en el DOM - `SoundChannel`, `HapticChannel`, futuros engines modales se registran como canales independientes que reciben la señal y deciden cómo materializarla en su plano ### Registry de canales — abierto El conjunto de canales **NO está cerrado**. El framework ships con firmas canónicas para `sound` y `haptic`. El canal `visual` existe como meta-canal de proyección/hold, pero no tiene slice propia en `EffectiveSignature`. Apps pueden añadir canales con firma vía declaration merging: ```ts // app bootstrap declare module '$uix/sema' { interface SemaChannelSignatures { a11y: A11ySignature; // narrador / live-region voice: VoiceSignature; // text-to-speech } } ``` Sólo el visual channel es obligatorio (con escape `visual: false`). Sound y haptic son opt-in. Cualquier canal nuevo registra su `Channel` y recibe el dispatch. ## El contrato `emit` ```ts semantic.emit(signal: SemanticSignal): Promise ``` Una sola firma. Cubre los tres escenarios cuando se compone con `dom.apply`: ```ts // Cambio estructural sin señal dom.apply(change); // Cambio estructural con señal await semantic.emit(signal); dom.apply(change); // Señal sin cambio estructural void semantic.emit(signal); ``` ### Semántica de la Promise — sequential strict `emit(signal)` resuelve cuando el `VisualChannel` ha completado su materialización entera: 1. el `VisualChannel.prepare()` proyectó `data-event*` al DOM 2. los atributos vivieron en el DOM durante el `hold` configurado 3. los atributos ya fueron retirados (cleanup completo) 4. la Promise resuelve Esto es **sequential strict**: el caller aplica el commit estructural DESPUÉS de que la señal perceptiva haya terminado. No hay paralelismo entre evento y cambio de estado. Los canales no-visuales (sound, vibra) son fire-and-forget: arrancan en paralelo con el visual pero no afectan al timing de la Promise. ### Ciclo de vida interno de `emit` ``` 1. Engine genera id/session de la ocurrencia 2. Engine ejecuta `channel.prepare(...)` en los canales registrados - `VisualChannel.prepare()` proyecta `data-event*` para la cascade 3. Engine resuelve la cascade y despacha la señal a TODOS los canales registrados - canales no-visuales (sound, vibra) → fire-and-forget (no awaited) - canal visual → awaited 4. Engine resuelve la Promise cuando el visual ha terminado (semántica secuencial estricta: cleanup ANTES del resolve) ``` ### Canales como módulos Sema está organizada en canales perceptivos simétricos: ``` src/uix/sema/ ├── engine.ts registry + channel prepare/dispatch + cascade composition ├── resolver.ts resolveSignature(signal, opts): EffectiveSignature ├── stamp.ts stampEventAttrs / unstampEventAttrs (data-event-*) ├── sema-map.ts SEMA_MAP + SemaChannelSignatures registry + Sema ├── components/ per-component perceptual packs (CSEM) │ ├── dialog.ts dialogSema — cascade rules + preloadSamples │ ├── toast.ts (futuro) │ └── … └── chans/ ├── types.ts interfaz Channel — handle(signal, effective) ├── visual.ts VisualChannel (data-event projection + hold) ├── sound.ts SoundChannel (Web Audio earcons + sample playback) └── haptic.ts HapticChannel (Vibration API + categorical kinds) ``` El engine no muta atributos DOM directamente: ejecuta el hook genérico `channel.prepare(...)`. En el canal visual, ese hook delega la proyección a un `SignalProjector`. Cuando lo construye `ActiveUix`, ese proyector escribe via el `ActiveDom` de UIX. El engine resuelve cada signal en una `EffectiveSignature` con `hold`, `sound`, `haptic` y futuros canales tipados, y dispatcha `(signal, effective)` a cada canal. Cada canal lee su slice (`effective.sound` para audio, `effective.haptic` para vibración, etc.) o ignora el signature si no lo usa. Solo el canal visual bloquea al caller con el hold perceptivo; los demás son fire-and-forget. ### Resolver y sema-map — cascada de 6 capas El engine, en cada `emit`: 1. Ejecuta los `prepare` de canales. El `VisualChannel` proyecta los tokens semánticos `data-event`, `data-event-family`, `data-event-intent`, `data-event-phase`, `data-event-id` al `signal.target`. Estos son los tokens que la cascade y la CSS de eidos leen. 2. Llama a `resolveSignature(signal, opts)` que aplica la cascada de 6 capas (cada una sobreescribe la anterior): ``` 1. FAMILY base — SEMA_MAP.families[signal.family].base sound / haptic + activeChannels + hold 2. INTENT deltas — SEMA_MAP.intents[signal.intent] cuando exista; numbers ADD por defecto — son modificadores) 3. soundPack URL — SEMA_MAP.soundPack[-] 4. MORFO overrides — signal.overrides + signal.channels (numbers REPLACE por defecto — son set values) 5. RUNTIME overrides — engineOpts.overrides.runtime (path-based globals; baked into el map en el constructor; numbers REPLACE) 6. CASCADE rules — engineOpts.components (per-component packs prepended) + engineOpts.overrides.cascade (app-level appended; gana en empate de specificity por declaration order). Selectors CSS-like contra signal.target con los data-event-* ya stampados; numbers REPLACE. ``` 3. Despacha a cada canal con la signature resuelta. 4. Awaita el VisualChannel (que aporta el hold). 5. Ejecuta el cleanup de los handles devueltos por `prepare`. **Convención de overrides vs deltas — números:** - **Capa 2 (intent.deltas)**: `pitch: -200` significa "restar 200 al pitch base". Modificadores compositivos. - **Capas 4, 5, 6 (overrides)**: `pitch: 720` significa "set pitch a 720". Como CSS — `gain: 0.4` no añade, asigna. - Para sumar explícitamente desde una capa de override: `{ op: 'add', value: 100 }`. - Para multiplicar: `{ op: 'multiply', factor: 1.2 }`. - Para reemplazar primitivos no-numéricos: `{ op: 'replace', value: ... }`. Si `signal.family` falta o no está en el map, devuelve un `EffectiveSignature` vacío — los canales hacen no-op. ### Tokens semánticos — `data-event-*` El `VisualChannel.prepare()` proyecta los siguientes attrs en `signal.target` ANTES de resolver la cascade. Las rules con selectores sobre estos attrs matchean nativamente vía `target.matches()` / `target.closest()`: | Attr | Valor | Origen | | ------------------- | -------------------- | ------------------------------- | | `data-event` | `'close-after-fail'` | `signal.name` | | `data-event-family` | `'signal'` | `signal.family` | | `data-event-intent` | `'threat'` | `signal.intent` (cuando exista) | | `data-event-phase` | `'active'` | mientras dure el hold | | `data-event-id` | `'sig-42'` | id de ocurrencia | Esos tokens son la **superficie de contacto cross-channel**: la cascade de sema (`sound`, `haptic` y futuros canales) los lee con selectores CSS, igual que el CSS de eidos los lee para tintar bordes / animar estados durante el hold. Una sola superficie perceptiva, con dueños separados. ### Cascade rules — forma plana CSS-like ```ts interface SemaCascadeRule { selector: string // CSS selector — matchea state attrs + tokens evento priority?: number // override de specificity CSS (opcional) channels?: readonly SemaChannelId[] // restringe / silencia canales activos sound?: … haptic?: … } ``` Una rule = un selector + un block de deltas. Sin capa intermedia `overrides: { eventLabel: ... }` — la identidad del evento se lee del selector mediante los tokens `[data-event*]`. ### Cascade selectors — typed builder, no hand-written strings Cascade rules in `sema/components/*.ts` MUST build their `selector` via `semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. The helper closes the loop between morfo's part/event contract and the selectors the cascade evaluates. ```ts import { semaSelector } from '$uix/morfo'; import { dialogMorfo } from '$uix/morfo/components/dialog'; const onContent = (matchers?: Parameters>[2]) => semaSelector(dialogMorfo, 'content', matchers); cascade: [ // [data-dialog-content][data-event-family="commit"] { selector: onContent({ eventFamily: 'commit' }), haptic: { kind: 'tap' } }, // [data-dialog-content][data-event="close-after-fail"] { selector: onContent({ eventName: 'close-after-fail' }), sound: { sampleUrl: '/fail.wav' } }, // [data-dialog-content][data-event^="close-"][data-event-family="emerge"] { selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }), sound: { contour: 'descending', pitch: { op: 'add', value: -150 } } } ]; ``` Compile-time guarantees: - `partKebab` is checked against `morfo.parts[].kebab`. - `eventName` is checked against `morfo.events[].name`. - `eventFamily` / `eventIntent` are typed against the canonical sema unions. - `state` / `aria` / `pseudo` accept plain strings (the data-attr vocabulary is per-component and not yet typed-derived). Renaming a part or event in morfo breaks the cascade at type-check time, not silently in production. **Hand-written selector strings in cascade rules are a code smell** — review them as drift. ### Per-component packs — `sema/components/{name}.ts` Cada componente trae su pack de defaults perceptivos en `src/uix/sema/components/{name}.ts`, simétrico a soma y eidos: ```ts import type { Sema } from '../sema-map'; export const dialogSema: Sema = { name: 'dialog', preloadSamples: ['/sounds/dialog/saved.wav', '/sounds/dialog/failed.wav'], cascade: [ { selector: '[data-dialog-content][data-event-intent="threat"]', sound: { sampleUrl: '/sounds/dialog/failed.wav' }, haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] } } ] }; ``` App importa los packs que use (tree-shakable): ```ts import { dialogSema } from '$uix/sema/components/dialog'; defineEngineSemantic({ components: [dialogSema], overrides: { cascade: [ // App-level rules ganan sobre los packs en empate de specificity { selector: '#critical [data-dialog-content]', sound: { sampleUrl: '/x.wav' } } ] } }); ``` `preloadSamples` se concatena entre todos los packs y se le pasa a `SoundChannel.preloadSamples()` al crear el engine cuando la app lo pide explicitamente — los WAVs pueden quedar decodeados antes del primer emit. El listener global de unlock no se registra en el constructor del canal; se instala solo cuando existe un `AudioContext` que desbloquear. ### Canales y signatures | Canal | Slice consumido | Comportamiento si no aplica | | -------- | -------------------------------------------------------------------- | ---------------------------------------------------------------- | | `visual` | `effective.hold` para hold | Cae al family fallback table (SEMA_DURATIONS) o al `defaultHold` | | `sound` | `effective.sound` (skip si `'sound'` no en activeChannels) | no-op | | `haptic` | `effective.haptic` (Vibration API, respeta `prefers-reduced-motion`) | no-op si no hay `navigator.vibrate` | | (custom) | declaración merging del `SemaChannelSignatures` | leído por el canal registrado | ### Namespace de attrs del canal visual El `VisualChannel.prepare()` proyecta **solo** atributos bajo el prefijo `data-event-*`: | Attr | Cuándo | | ------------------- | -------------------- | | `data-event` | siempre | | `data-event-id` | siempre | | `data-event-phase` | siempre (`'active'`) | | `data-event-family` | si `signal.family` | | `data-event-intent` | si `signal.intent` | **Regla**: el canal nunca toca state attrs (`data-state`, `data-intent`, `data-disabled`, ...). El estado lo gestiona el runtime/morfo. Razón: state es persistente y signal es transitorio; pisar el mismo nombre fuerza al canal a borrar estado al limpiar (o a save/restore frágil si el estado muta durante el hold). Para CSS: - `[data-event-intent='risk']` → reacciona al intent de la ocurrencia transitoria - `[data-intent='risk']` → reacciona al estado persistente del componente Ambos pueden coexistir en el mismo elemento con semánticas distintas. ### Hold — cadena de resolución del canal visual El `VisualChannel` resuelve su `hold` (cuánto viven los `data-event-*` en el DOM) en este orden: 1. `signal.hold` — override imperativo per llamada. 2. `effective.hold` — viene del resolver desde `SEMA_MAP.families[*].hold`. 3. Family fallback table (`SEMA_DURATIONS` + label per familia) — defensa si una familia externa no declara `hold`. 4. `defaultHold` global del canal (240ms por defecto). Ver `SEMA_MAP.families[*].hold` para los valores concretos por familia. ```ts // Override per signal semantic.emit({ ..., hold: 1200 }) // Override default global del canal visual const semantic = new EngineSemantic({ dom, visual: { defaultHold: 400 } }) // Desactivar visual (entornos sin DOM) const semantic = new EngineSemantic({ visual: false }) // Activar el canal de sonido built-in (opt-in: tiene side effect audible) const semantic = new EngineSemantic({ dom, sound: true }) // Con opciones de SoundChannel const semantic = new EngineSemantic({ dom, sound: { masterGain: 0.6 } }) // Activar el canal háptico built-in (opt-in: vibra el dispositivo) const semantic = new EngineSemantic({ dom, haptic: true }) // Con opciones de HapticChannel const semantic = new EngineSemantic({ dom, haptic: { masterIntensity: 0.7 } }) // Registrar canales a medida (a11y, voice, futuros) class A11yChannel implements Channel { readonly id = 'a11y' async handle(signal, effective) { /* live-region updates, etc. */ } } semantic.register(new A11yChannel()) ``` ### Override layers — recipes ```ts import { dialogSema } from '$uix/sema/components/dialog'; const semantic = new EngineSemantic({ dom, sound: true, haptic: true, // Capa 6a — packs de componentes (defaults shipped con cada componente) components: [dialogSema /* , toastSema, drawerSema, … */], overrides: { // Capa 5 — edits puntuales del SEMA_MAP, válidos a TODA la app runtime: { 'families.commit.base.sound.pitch': 850, 'intents.threat.deltas.haptic.intensity': 0.4 }, // Capa 6b — rules CSS-like de la app, matched contra signal.target // AFTER de los packs de componente cascade: [ { selector: '#delete-confirm-dialog [data-dialog-content][data-event-intent="threat"]', sound: { sampleUrl: '/sounds/scary.wav', gain: 0.25 }, haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] } }, { selector: ':root[data-sound="reduce"] [data-event-phase="active"]', priority: 100, sound: { gain: 0.05 }, channels: ['sound'] } ] } }); ``` ```ts // Capa 4 — per-event override declarado en la propia morfo del componente. // Propaga vía SomaRuntime → SemanticSignal → resolver. La app puede // seguir sobreescribiendo desde la cascade (capa 6). // src/uix/morfo/components/dialog.ts { name: 'close-after-fail', semantic: { family: 'signal', verb: 'alert', target: v.partRef('content'), sequence: 'pre', intent: 'threat', // Per-event silencing: sin sonido para evitar competir con el live-region channels: ['haptic'], // Per-event override: sample propio del Dialog overrides: { haptic: { kind: 'error', pattern: [60, 80, 60, 80, 60] } } } } ``` ### Specificity entre rules Como CSS: - **Specificity computada del selector** — IDs × 100, atributos × 10, classes × 10, pseudo-classes × 10, elementos × 1. - **Rules con specificity ascendente se aplican en orden** — la última aplicada gana en cada conflicto puntual. - **Empate** → orden de declaración. Componentes packs aparecen ANTES de `overrides.cascade`, así app rules ganan en empate. - **`priority?: number`** — override del valor computado para casos que necesitan ganar sin contar atributos (típicamente accesibilidad `priority: 100+`). Para variar por evento, intent, family, name, etc. — todo va en el selector mediante los tokens `[data-event-*]`: ```ts // Variación por intent del evento { selector: '[data-toast-root][data-event-intent="threat"]', haptic: { kind: 'error' } } // Variación por family { selector: '[data-toast-root][data-event-family="signal"]', sound: { gain: 0.4 } } // Variación por nombre exacto de evento { selector: '[data-toast-root][data-event="close-after-fail"]', sound: { sampleUrl: '/fail.wav' } } // Variación por prefijo de evento { selector: '[data-dialog-content][data-event^="close-"]', sound: { contour: 'descending' } } ``` ### SoundChannel Earcons cortos sintetizados vía Web Audio API a partir de `effective.sound` (pitch / centroid / roughness / attack / decay / duration / contour / gain). Detalles: - Un único `AudioContext` con `GainNode` master por engine. - **Prepare-time priming**, no side-effect en el constructor. El canal crea + resume el `AudioContext` en `prepare()` cuando la señal admite `sound`, de forma síncrona dentro del gesto de usuario. - Despues de crear el contexto, registra un listener de `click` / `touchstart` / `keydown` en `document` (capture phase) para re-resume tras suspends pasivos (tab switch, etc.). Si el canal nunca prepara una señal sonora, no instala listeners globales. - Síntesis: dos osciladores (sine + 5ª) → biquad lowpass (centroid) → envelope ADSR-lite. Si `roughness > 0.2`, modulador AM rápido. - `contour` (`flat` / `ascending` / `descending` / `arc` / `bell`) se aplica vía `osc.detune`. - Si la signature trae `sampleUrl`, reproduce el sample (con caché de `AudioBuffer`) en lugar de sintetizar. - Cualquier fallo (no-AudioContext, decode failure) se absorbe — sema es ornamental. El patrón prepare-time priming aplica en general a cualquier canal cuyo backend tenga restricción de "primera vez ha de ocurrir en gesture": audio, vibration, fullscreen, clipboard write. Documentado como **convención 12** en [`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md). ### Política de errores - Errores en canales NO-visuales se loguean pero no propagan. Sema es ornamental: un fallo de audio context o vibration API no debe abortar la operación del provider. - Si el canal visual lanza, la Promise de `emit` rechaza. El caller decide. - En fire-and-forget (`void semantic.emit(...)`), una rejection del visual se propaga como unhandled promise — política consciente. ## Política de intent por familia — `SEMA_FAMILY_POLICY` La doctrina sobre cuándo el intent es obligatorio vive en una const en `src/uix/sema/types.ts`. Tres niveles: ```ts export const SEMA_FAMILY_POLICY = { contact: { intentPolicy: 'allowed' }, // OPCIONAL — neutral por default commit: { intentPolicy: 'expected' }, // REQUERIDO — toda consumación es evaluativa signal: { intentPolicy: 'expected' }, // REQUERIDO — alarmas son inherentemente evaluativas handle: { intentPolicy: 'allowed' }, // OPCIONAL — drag/scrub suelen ser neutrales emerge: { intentPolicy: 'optional' }, // OPCIONAL — Dialog que abre para confirmar threat sí declara shift: { intentPolicy: 'optional' }, sustain: { intentPolicy: 'optional' } } as const; ``` Cada entry es un objeto — anticipa otros campos doctrinales por familia (default sequence, channels permitidos, hold preferences, gesture phases, …). Cuando se añadan se ubicarán dentro del mismo objeto sin reestructurar. **Cómo se aplica:** 1. **Compile time** — `SemaEvent` y `MorfoEventSemantic` son discriminated unions derivadas de la const. Cambiar `'optional'` → `'expected'` en `contact` (por ejemplo) hace que cada morfo de toggle/switch/etc. tenga que declarar intent o falle el typecheck. 2. **Runtime** — `validateSemaEvent` lanza `SemaInvariantError` cuando un evento de family `'expected'` se construye sin intent (defensa contra morfos mal-formados o inputs externos). **Por qué esta política reemplazó al split valenced/transitional:** La doctrina canónica original asumía que sólo familias valenced (contact, commit, signal, handle) podían declarar intent. Las transitional (emerge, shift, sustain) eran intent-less por definición. La realidad UX lo contradijo: un Dialog que se abre para confirmar borrar algo destructivo carga threat en su misma aparición. La política distingue por NECESIDAD práctica de intent, no por categoría taxonómica. La distinción valenced/transitional sigue existiendo como clasificación, pero ya no dicta las reglas de intent — eso lo hace la política. ## Vocabulario canónico de verbs (`SEMA_VERBS`) Cross-component action verbs grouped by family per [`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md) §1.3. `morfo.events[].name` debería alinear con este vocabulario para que sema/sound/vibra puedan suscribir por verb y eidos pueda escribir selectores transversales (`[data-event^=dismiss]`). ```ts SEMA_VERBS = { contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'], commit: [ 'select', 'toggle', 'save', 'submit', 'confirm', 'cancel', 'complete', 'fail', 'delete', 'restore', 'reset', 'discard', 'expire', 'acknowledge', 'set', 'remove', 'reorder' ], signal: ['announce', 'notify', 'warn', 'alert', 'emphasize', 'remind'], handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'], 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', 'end' ] }; ``` **Verbs que parecen de una familia pero pertenecen a otra** per la canónica: `select`/`toggle`/`acknowledge` son `commit` (fijan estado); `edit` se expresa como `shift.enter-mode` (cambia régimen, no hay verb `edit` en handle). Definido en [`verbs.ts:SEMA_VERBS`](./verbs.ts). ### Naming shapes Un `morfo.events[].name` puede tomar dos formas canónicas: ```ts // Forma 1: {verb}-{variant} — head es el verbo, tail explica el matiz. 'dismiss'; // bare verb 'dismiss-outside'; // verb + variant 'close-cancel'; // verb (close) + variant (cancel) // Forma 2: {family}-{verb} — head es la familia, tail es el verb canónico. 'commit-toggle'; // family=commit, verb=toggle 'commit-save'; // family=commit, verb=save ``` `validateEventName(name)` reconoce ambas formas y devuelve `{ family, verb, variant, matchesCanonical }`. Advisory — no rechaza morfos, solo flagea drift para tooling y revisión. ```ts import { validateEventName } from '$uix/sema'; validateEventName('commit-toggle'); // { name: 'commit-toggle', head: 'commit', matchesCanonical: true, // variant: 'toggle', family: 'commit', verb: 'toggle' } validateEventName('dismiss-outside'); // { name: 'dismiss-outside', head: 'dismiss', matchesCanonical: true, // variant: 'outside', family: 'emerge', verb: 'dismiss' } validateEventName('frob-glob'); // { name: 'frob-glob', head: 'frob', matchesCanonical: false, // variant: 'glob', family: undefined, verb: undefined } ``` ## Relación con Morfo y Soma - `Morfo` declara los eventos semánticos del componente en `morfo.events` - `Provider` decide cuándo ocurren y llama a `semantic.emit(...)` - `SomaRuntime` orquesta la secuencia `prewrite -> emit -> handler -> effects` - `Sema` aporta el vocabulario, la normalización y la validación del dominio, y publica las ocurrencias ## Dependencias - `EngineSemantic` no escribe atributos directamente. Orquesta hooks de canales (`prepare`, `handle`, `cleanup`) sin conocer los attrs DOM. - Cada canal gestiona su propia modalidad: - `VisualChannel.prepare()` proyecta `data-event-*` y luego mantiene el hold perceptivo. - `DomSignalProjector` es el escritor DOM usado por el canal visual; escribe mediante el `ActiveDom` recibido desde `ActiveUix`. - Futuros canales (sound, vibra) accederán a sus APIs respectivas (`AudioContext`, `navigator.vibrate`, etc.). - En uso normal, `ActiveUix` inyecta el `ActiveDom` en `EngineSemantic`. El uso directo de Sema fuera de `ActiveUix` debe pasar un `dom/projector` explicito o elegir una degradacion documentada. ## Regla de arquitectura `Morfo` autoriza la semántica del componente. `Sema` define el vocabulario canónico y despacha señales a los canales. `Provider` decide cuándo emitir. Cada `Channel` materializa la señal en su modalidad. Ver [src/uix/README.md](../README.md) §2.bis para la vista cross-layer y [`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md) para las convenciones doctrinales del API (single-event para operaciones instantáneas, intent ↔ visual token resolution, sound prepare-time priming, etc.).