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/sema/README.md

711 lines
27 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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<void>
```
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, haptic) 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, haptic) → 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-*)
├── channels.ts channel ids, signatures and override types
├── sema-map.ts SEMA_MAP data + per-component Sema packs
├── 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[<family>-<intent>]
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<typeof semaSelector<typeof dialogMorfo>>[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: feedback del 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` via la superficie DOM inyectada
(`ActiveDom.listen(ActiveDom.getDocument(), ...)`) 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/haptic 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, haptic) 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.).

Powered by TurnKey Linux.