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

29 KiB

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 (8): contact, commit, signal, handle, emerge, shift, sustain, delegate
  • intents canónicos: neutral, affirm, fulfill, risk, threat, loss
  • política de intent por familia (SEMA_FAMILY_POLICY), dos ejes: intentRequirement ('required' | 'optional' | 'forbidden') — el gate de compilación que da forma a la unión discriminada — e intentGuidance ('expected' | 'contextual' | 'discouraged') — hint doctrinal para lint/tooling
  • 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).

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:

// 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

semantic.emit(signal: SemanticSignal): Promise<void>

Una sola firma. Cubre los tres escenarios cuando se compone con dom.apply:

// 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
├── sounds.ts             repositorio nominal de sonidos + recetas dinámicas
├── 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 5 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 5 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. MORFO overrides   — signal.overrides + signal.channels
                       (numbers REPLACE por defecto — son set values)
4. RUNTIME overrides — engineOpts.overrides.runtime (path-based globals;
                       baked into el map en el constructor; numbers REPLACE)
5. 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.
  1. Despacha a cada canal con la signature resuelta.
  2. Awaita el VisualChannel (que aporta el hold).
  3. 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 3, 4, 5 (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

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.

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:

import type { Sema } from '../sema-map';
import { sound, soundSampleUrls } from '../sounds';

export const dialogSema: Sema = {
	name: 'dialog',
	preloadSamples: soundSampleUrls(['notification.ping']),
	cascade: [
		{
			selector: '[data-dialog-content][data-event-intent="threat"]',
			sound: sound('notification.ping'),
			haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
		}
	]
};

App importa los packs que use (tree-shakable):

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.

Repositorio de sonidos — sounds.ts

Los componentes no deben declarar firmas sonoras completas en cada pack. Sema tiene un repositorio nominal:

import { sample, sound, soundTuning } from '$uix/sema';

sound('handle.pickup.air');
soundTuning('emerge.exit.deep');

Una entrada puede ser sintética o apuntar a un fichero externo .wav con fallback sintético:

sample('/sounds/uix/dialog-fail.wav', sound('handle.release.soft'), { preload: true });

SoundChannel ya entiende sampleUrl: intenta reproducir el WAV y, si fetch/decode falla, cae a la firma sintética. La regla es que los componentes referencian nombres; las URLs y parámetros viven en un solo sitio.

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.

// 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

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']
			}
		]
	}
});
// 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-*]:

// 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.

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

Canonical: docs/CANON.md §4 is the single source for this policy. The shape below mirrors SEMA_FAMILY_POLICY in types.ts; if they ever disagree, the code + canon win.

La doctrina sobre cuándo el intent es obligatorio vive en una const en src/uix/sema/types.ts. Cada familia declara dos ejes independientes:

export const SEMA_FAMILY_POLICY = {
	contact: { intentRequirement: 'optional', intentGuidance: 'discouraged' },
	commit: { intentRequirement: 'required', intentGuidance: 'expected' },
	signal: { intentRequirement: 'required', intentGuidance: 'expected' },
	handle: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	emerge: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	shift: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	sustain: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	delegate: { intentRequirement: 'optional', intentGuidance: 'contextual' }
} as const;
  • intentRequirement ('required' | 'optional' | 'forbidden') — el gate de compilación que da forma a la unión discriminada. 'required' (solo commit y signal) → intent es OBLIGATORIO en MorfoEventSemantic. 'forbidden' está reservado; ninguna familia lo usa hoy.
  • intentGuidance ('expected' | 'contextual' | 'discouraged') — hint doctrinal, sin efecto de tipo. commit/signal son 'expected'; contact es 'discouraged' (cap. 22 §11: "el intent fuerte no debería vivir en el contacto"); el resto es 'contextual'.

Cómo se aplica:

  1. Compile time — SemaEvent y MorfoEventSemantic son uniones discriminadas derivadas del eje intentRequirement. Cambiar 'optional' → 'required' en una familia obliga a cada morfo de esa familia a declarar intent o falla el typecheck.

  2. Runtime — validateSemaEvent lanza cuando un evento de una familia con intentRequirement: 'required' 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)

Canonical: docs/CANON.md §6 + verbs.ts are the source of truth for the verb vocabulary. This section documents how sema consumes it (validation, naming shapes).

Cross-component action verbs grouped by family. El canon vive en verbs.ts y refleja src/docs/Disenando_lo_que_ocurre_manuscrito_completo_revisado_v2.docx cap. 22-29 (familias) + cap. 10 (intents). morfo.events[].semantic.verb DEBE estar en este vocabulario; morfo.events[].name debería seguir la forma {family}-{verb}[-{variant}] para que sema/sound/haptic puedan suscribir por verb y eidos pueda escribir selectores transversales ([data-event^="dismiss"]).

SEMA_VERBS = {
	contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'],
	commit: [
		'select', 'toggle', 'save', 'submit', 'confirm', 'complete', 'fail',
		'cancel', 'reset', 'discard', 'delete', 'restore', 'expire',
		'acknowledge',
		// Verbos contextuales del libro (cap. 29 + cap. 30 + apéndice C):
		'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'],
	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'
	],
	// Familia delegate (libro cap. 29): eventos donde cambia quién lleva la
	// iniciativa. No requiere IA — workflows, macros, aprobaciones.
	delegate: ['offer', 'plan', 'authorize', 'act', 'review', 'escalate', 'return']
};

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.

Naming shapes

Un morfo.events[].name puede tomar dos formas canónicas:

// 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 }. Usada por scripts/morfo-vocabulary-check.ts (npm run morfo:vocabulary), que hard-falla cuando el semantic.verb declarado en un morfo no está en el canon de su familia, y soft-warns cuando la NAME del evento no calza con {family}-{verb}[-{variant}] aun siendo el verb canónico. Allowlist temporal en el script para divergencias deliberadas.

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 §2.bis para la vista cross-layer y src/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.