sema: refactor into channel modules — engine becomes registry + dispatch

Reorganización estructural según el spec acordado: cada canal perceptivo
vive como módulo simétrico dentro de `chans/`. El engine queda mínimo
(registry + dispatch); la lógica de escribir `data-event*` al DOM,
mantener hold y retirar pasa al `VisualChannel`. La API pública que ven
los providers (`semantic.emit(signal)`) se mantiene idéntica.

Estructura nueva
src/uix/sema/
├── engine.ts             registry + dispatch (ya no conoce DOM ni hold)
├── signal.ts             SemanticSignal + nuevo campo opcional `id?`
├── exports.ts            barrel actualizado (chans + drop publish + drop perception)
└── chans/
    ├── types.ts          interfaz Channel { id, handle, dispose? }
    ├── visual.ts         VisualChannel — escribe data-event*, hold, cleanup
    ├── visual.test.ts    9 tests del canal aislado
    ├── sound.ts          SoundChannel placeholder (V1 no-op)
    └── vibra.ts          VibraChannel placeholder (V1 no-op)

Cambios al engine
- Constructor: `new SemanticEngine(opts?)`. `opts.visual` controla el
  built-in (false / VisualChannelOptions / Channel custom). `opts.dom`
  desaparece — ya no es responsabilidad del engine.
- Métodos: `register(channel)`, `getChannel(id)`, `emit(signal)`,
  `destroy()`. Nada más.
- emit despacha a TODOS los canales registrados:
  - Canales no-visuales (sound, vibra, futuros): fire-and-forget. Errores
    se loguean pero no propagan (Sema es ornamental).
  - Canal visual: el único cuya Promise se awaitea — comparte plano DOM
    con el commit estructural posterior.
- emit genera el `id` de la ocurrencia y lo pasa a todos los canales,
  garantizando coherencia cross-canal para tooling futuro.

Cambios al VisualChannel (lógica heredada del emit() anterior)
- 5 atributos: `data-event` + `data-event-id` + `data-event-phase`
  (siempre) + `data-event-family` y `data-intent` (opcionales si vienen
  en el signal). Los opcionales son la proyección al DOM de la metadata
  semántica que Eidos consume.
- Hold defaults internos por familia (no exportados):
    emerge/commit/handle: 240ms · alert/sustain: 600ms · contact: 120ms
  Justificación técnica (rangos típicos de CSS transitions), no escala
  perceptiva universal. Override per signal vía `signal.hold` o global
  vía `new SemanticEngine({ visual: { defaultHold } })`.
- Semántica secuencial estricta: cleanup ANTES del resolve.

Eliminado
- `src/uix/sema/perception.ts` — escalas perceptivas no son concepto
  cross-canal. La tabla de defaults por familia se movió al VisualChannel
  como detalle interno con justificación técnica.
- `engine.publish()` y todo el sistema legacy:
  - `SemanticEventDecl`, `SemanticComponentContract`, `SemanticPublishContext`,
    `PublishedSemanticEvent`, `SemanticEventFilter`
  - `onEvent()` y subscribers
  - `applyPrewrites()` interno
  - dependencia opcional de `ActiveDom` por construcción
- Tests de `publish()` reemplazados por tests del nuevo registry/dispatch.

Bootstrap (App + defaults)
- `src/lib/ext/app/app.svelte.ts:52` — `new SemanticEngine({ dom: this.dom })`
  → `new SemanticEngine()`
- `src/lib/ext/app/defaults.ts:45` — mismo cambio

Tests
- emit.test.ts ajustado: sin createActiveDom, sin SEMA_PERCEPTION/HOLD_DEFAULTS,
  test "throws without dom" eliminado, números literales (240, 600).
  Añadido test "resolves immediately when visual: false". 7 tests.
- engine.test.ts reescrito: register / getChannel / emit dispatch a múltiples
  canales / id propagation / id override / fire-and-forget para non-visual /
  error en canal no aborta dispatch / visual: false desactiva built-in /
  custom Channel reemplaza built-in / destroy. 13 tests.
- chans/visual.test.ts nuevo: 5 attrs vs 3 / signal.hold respetado /
  family default fallback / global default fallback / constructor defaultHold /
  cleanup / sequential strict (resolve tras cleanup) / id field. 9 tests.
- 39 tests sema verdes en total.

Documentación
- src/uix/sema/README.md — reescrito alrededor del modelo de canales
- src/uix/eidos/events.css — comentario actualizado, referencia a chans/visual.ts
- src/uix/active_architecture.md — sección Sema reescrita

Componentes NO requieren cambios — la API `semantic.emit(signal)` es
idéntica externamente. Toast / runtime.trigger / providers funcionan igual.

Verificación
- 121/121 tests focused suite (sema 39 + adom + lib/dom + morfo + soma/morfo)
- 66/66 morfo-check
- 68/68 smoke routes
- svelte-check: 155 errors (baseline, sin nuevos)

Pendientes en doc del spec marcados como "no-objetivos":
- Implementación real de SoundChannel / VibraChannel (placeholders V1)
- Arbitraje propio del VisualChannel (deuda heredada del emit anterior)
- Cancelación si el target se desconecta durante el hold
morfo-runtime
dev 5 months ago
parent e664b59c96
commit 5871cbca6e

@ -49,7 +49,7 @@ export class App {
constructor(opts: AppOptions) {
this.dom = opts.dom ?? createActiveDom();
this.semantic = opts.semantic ?? new SemanticEngine({ dom: this.dom });
this.semantic = opts.semantic ?? new SemanticEngine();
this.langs = opts.langs;
this.nums = opts.nums;
this.money = opts.money;

@ -42,4 +42,4 @@ export const fallbackPresentation: AppPresentation = {
export const fallbackDom = createActiveDom({});
/** Default semantic engine wired to the fallback dom. */
export const fallbackSemantic: AppSemantic = new SemanticEngine({ dom: fallbackDom });
export const fallbackSemantic: AppSemantic = new SemanticEngine();

@ -105,30 +105,48 @@ 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 — el canal semántico
### Sema — vocabulario + canales perceptivos
`Sema` define el vocabulario canónico del framework — familias semánticas
(`emerge`, `commit`, `alert`, `handle`, `contact`, `sustain`), intents
(`neutral`, `affirm`, `fulfill`, `risk`, `threat`), action verbs (`present`,
`dismiss`, `commit`, `cancel`, `announce`, ...) — y orquesta la **emisión
de señales perceptivas** al DOM.
Sema **no decide qué evento ocurrió** — eso lo decide el provider. Sema
recibe la ocurrencia que el provider emite y le da forma canónica:
- escribe `data-event="dismiss"` + `data-event-id` + `data-event-phase` +
`data-intent="risk"` + `data-event-family="emerge"` en el target
- espera 1 rAF para que CSS pueda observar la señal
- resuelve la Promise (el caller continúa con el cambio estructural)
- mantiene la señal `hold` ms — **anclado a escalas perceptivas humanas**,
no a frames técnicos: `brief` (240ms) para emerge/commit/handle,
`noticed` (600ms) para alert/sustain. Defaults en
`src/uix/sema/perception.ts:SEMA_HOLD_DEFAULTS`
- limpia los `data-event*`
La duración del signal es **perceptual por diseño**, no de framerate. Una
señal de `alert` vive 600ms para que un humano la perciba directamente;
una de `emerge` vive 240ms suficientes para que CSS dispare una animación
y el usuario la note al menos como "algo pasó".
`dismiss`, `commit`, `cancel`, `announce`, ...) — y orquesta el **dispatch
de señales perceptivas** a un conjunto de canales modulares.
Sema **no decide qué evento ocurrió** — eso lo decide el provider. El
`SemanticEngine` solo:
- mantiene un registry de canales que implementan `Channel`
- genera el `id` de cada ocurrencia
- 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 + dispatch
└── chans/
├── types.ts interfaz Channel
├── visual.ts VisualChannel (built-in, escribe data-event*)
├── sound.ts SoundChannel (placeholder)
└── vibra.ts VibraChannel (placeholder)
```
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.
Escribe `data-event` + `data-event-id` + `data-event-phase` (y opcionalmente
`data-event-family` y `data-intent`) al target, mantiene los atributos
durante un `hold` configurable, los retira, y resuelve la Promise
(semántica secuencial estricta).
Defaults técnicos de hold por familia, internos al canal visual:
emerge/commit/handle 240ms, alert/sustain 600ms, contact 120ms. El
integrador puede subir el default global vía `new SemanticEngine({ visual: { defaultHold } })`
o per signal vía `signal.hold`.
Los canales **sound** y **vibra** son placeholders en V1 — registry
preparado para que cuando se implementen, encajen sin sorpresas
estructurales. Cuando lleguen, son fire-and-forget: gestionan su propio
timing en sus respectivos planos (audio, hardware háptico) sin afectar
al caller.
### Eidos — la capa visual

@ -1,14 +1,17 @@
/**
* Eidos — reactions to perceptual signals (`data-event*`).
*
* Sema writes `data-event="..." data-event-phase="active" data-intent="..."`
* to the target during a perceptually-anchored hold window:
* - `brief` (240ms) for emerge / commit / handle families
* - `noticed` (600ms) for alert / sustain families
* Defaults from `src/uix/sema/perception.ts:SEMA_HOLD_DEFAULTS`; authors
* override per-signal via `SemanticSignal.hold`.
* The VisualChannel of Sema writes `data-event`, `data-event-id`,
* `data-event-phase` (and optionally `data-event-family` and `data-intent`)
* to the target during a configurable hold window. Defaults per family
* are internal to `src/uix/sema/chans/visual.ts`:
* - emerge / commit / handle: 240ms
* - alert / sustain: 600ms
* - contact: 120ms
* Authors override per signal via `signal.hold`, or globally via
* `new SemanticEngine({ visual: { defaultHold } })`.
*
* Even with the perceptual hold, prefer `animation: @keyframes` over
* Even with the configured hold, prefer `animation: @keyframes` over
* `transition` for the visible response: the animation runs to completion
* independent of the signal's lifetime, so it works correctly with custom
* holds and won't reverse mid-flight if the signal is cleared early.

@ -66,59 +66,76 @@ limpieza (la señal ya no estaría visible cuando el commit estructural entre).
### Ciclo de vida interno de `emit`
```
1. Sema genera id/sesion del evento
2. Sema llama a dom.apply(eventSignal) // data-event, data-event-phase, data-intent
3. Sema espera 1 rAF // ~16ms — boundary técnico del compositor
4. Sema resuelve la Promise // <- el caller hace su dom.apply estructural
5. Sema mantiene la señal `hold` ms // anclado a escalas perceptivas humanas
6. Sema llama a dom.apply(remove eventSignal)
1. Engine genera id/session de la ocurrencia
2. Engine despacha la señal a TODOS los canales registrados
- canales no-visuales (sound, vibra) → fire-and-forget (no awaited)
- canal visual → awaited
3. Engine resuelve la Promise cuando el visual ha terminado
(semántica secuencial estricta: cleanup ANTES del resolve)
```
### Duración del signal — anclada en percepción humana
### Canales como módulos
La duración (`hold`) está en **milisegundos anclados a escalas perceptivas
canónicas**, no en frames de rAF. Definidas en
[`perception.ts`](./perception.ts):
Sema está organizada en canales perceptivos simétricos:
| Escala | ms | Uso |
|---|---|---|
| `subliminal` | 50 | Bajo umbral consciente; integrado como un instante |
| `glimpse` | 120 | Mínimo perceptible sin esfuerzo |
| `brief` | 240 | Acknowledge breve (button press, micro-feedback) |
| `noticed` | 600 | Sustained signal (announce pulse) |
| `insistent` | 1200 | Demands attention (warnings, errors) |
| `persistent` | 3000 | Hasta acknowledge o auto-dismiss |
```
src/uix/sema/
├── engine.ts registry + dispatch
└── chans/
├── types.ts interfaz Channel
├── visual.ts VisualChannel (built-in, escribe data-event* al DOM)
├── sound.ts SoundChannel (placeholder V1)
└── vibra.ts VibraChannel (placeholder V1)
```
El engine no conoce DOM ni hold ni atributos. Cada canal materializa la
señal en su modalidad. Solo el canal visual bloquea al caller (comparte
plano DOM con el commit estructural posterior); los demás son
fire-and-forget.
### Hold — política técnica del canal visual
Defaults por familia en `SEMA_HOLD_DEFAULTS`:
El `VisualChannel` mantiene los atributos `data-event*` en el DOM durante
un `hold` configurable. Defaults internos del canal por familia:
| Family | Default | Razón |
|---|---|---|
| `emerge` | `brief` (240ms) | Aparición/desaparición, breve |
| `sustain` | `noticed` (600ms) | Estado en curso |
| `contact` | `glimpse` (120ms) | Primer contacto, blink-and-miss vale |
| `commit` | `brief` (240ms) | Confirmación |
| `alert` | `noticed` (600ms) | Anuncio que se quiere notar |
| `handle` | `brief` (240ms) | Marcador sostenido |
| Family | Hold |
|---|---|
| `emerge` | 240ms |
| `sustain` | 600ms |
| `contact` | 120ms |
| `commit` | 240ms |
| `alert` | 600ms |
| `handle` | 240ms |
Override por evento via `SemanticSignal.hold`:
Default global (cuando ni signal.hold ni la familia lo proporcionan): 240ms.
Estos números reflejan rangos típicos de CSS transitions para cada tipo
de feedback. El integrador puede subir el default global vía
`new SemanticEngine({ visual: { defaultHold: ... } })` o per signal vía
`signal.hold`.
```ts
import { SEMA_PERCEPTION } from '$uix/sema'
// Override per signal
semantic.emit({ ..., hold: 1200 })
semantic.emit({ ..., hold: SEMA_PERCEPTION.insistent }) // 1200ms
```
// Override default global del canal visual
const semantic = new SemanticEngine({ visual: { defaultHold: 400 } })
**El único timing técnico** del engine es el rAF de espera entre escritura
y resolve (paso 3). Es boundary del compositor (CSS necesita un frame para
hacer match), no decisión perceptual.
// Desactivar visual (entornos sin DOM)
const semantic = new SemanticEngine({ visual: false })
// Registrar canales adicionales (cuando estén implementados)
import { SoundChannel } from '$uix/sema'
semantic.register(new SoundChannel())
```
### Política de errores
- Si el cambio estructural lanza tras el `await`, no afecta a Sema. Su trabajo
(escribir señal + esperar frame) ya terminó. La cleanup pasa igual.
- Si Sema falla escribiendo la señal, la Promise rechaza. El caller decide si
aborta el cambio estructural o lo aplica igual.
- En el escenario fire-and-forget (`void semantic.emit(event)`), una rejection
- 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.
## Vocabulario canónico de verbs (`SEMA_VERBS`)

@ -0,0 +1,23 @@
import type { SemanticSignal } from '../signal'
import type { Channel } from './types'
/**
* Canal sonoro. Materializa señales como earcons cortos vía Web Audio API.
*
* V1: placeholder. La implementación real se desarrollará en una iteración
* posterior, incluyendo:
* - mapping evento → sample / parametrización (sound.def por app)
* - gestión de AudioContext con unlock visible en Safari
* - políticas de solapamiento, cola, cancelación
* - validación psicoacústica build-time
*
* Mientras tanto, este placeholder permite registrarlo opt-in en bootstrap
* sin que el código rompa, y documenta dónde vivirá la implementación.
*/
export class SoundChannel implements Channel {
readonly id = 'sound'
async handle(_signal: SemanticSignal): Promise<void> {
// V1: no-op. Implementación real pendiente.
}
}

@ -0,0 +1,50 @@
import type { SemanticSignal } from '../signal'
/**
* Interfaz canónica que cualquier canal perceptivo debe implementar.
*
* Los canales reciben señales del SemanticEngine vía `handle()` y
* materializan la señal en su modalidad. El engine no asume nada
* sobre cómo cada canal materializa — solo que respeta este contrato.
*
* Requisitos no negociables:
* 1. `handle()` nunca rechaza la Promise. Si la materialización falla,
* el canal lo gestiona internamente (log, fallback silencioso).
* Una rejection no debe abortar el dispatch a los demás canales.
* 2. `id` debe ser único entre canales registrados en el mismo engine.
* El engine valida en `register()` y lanza si hay duplicado.
* 3. Solo el canal visual (id `'visual'`) puede bloquear al caller
* durante el tiempo que necesite su materialización (escribir
* atributos + hold + retirar), porque comparte plano DOM con el
* commit estructural posterior. Los demás canales viven en planos
* independientes (audio, hardware háptico) y son fire-and-forget.
*/
export interface Channel {
/**
* Identificador del canal. Para introspección, debug, configuración.
* Convención: `'visual'`, `'sound'`, `'vibra'`, etc.
*/
readonly id: string
/**
* Materializa la señal en la modalidad del canal.
*
* Devuelve una Promise que resuelve cuando el canal ha completado
* su parte secuencial. Para el canal visual, la Promise resuelve
* tras la limpieza de atributos del DOM (semántica secuencial
* estricta — el caller aplica el commit estructural cuando la
* señal ya ha terminado de mostrarse).
*
* Los canales fire-and-forget (sound, vibra) reciben la señal
* pero no afectan al timing del caller — su trabajo arranca pero
* la Promise resuelve inmediatamente.
*/
handle(signal: SemanticSignal): Promise<void>
/**
* Permite al canal liberar recursos al desmontar la aplicación.
* Implementación opcional — no todos los canales tienen recursos
* que limpiar.
*/
dispose?(): void
}

@ -0,0 +1,24 @@
import type { SemanticSignal } from '../signal'
import type { Channel } from './types'
/**
* Canal háptico. Materializa señales como patrones de vibración vía
* Vibration API en dispositivos móviles compatibles.
*
* V1: placeholder. La implementación real se desarrollará en una iteración
* posterior, incluyendo:
* - mapping evento → patrón (vibra.def por app)
* - capability detection (navigator.vibrate disponible)
* - respeto a prefers-reduced-motion como proxy
* - políticas de solapamiento
*
* Mientras tanto, este placeholder permite registrarlo opt-in en bootstrap
* sin que el código rompa, y documenta dónde vivirá la implementación.
*/
export class VibraChannel implements Channel {
readonly id = 'vibra'
async handle(_signal: SemanticSignal): Promise<void> {
// V1: no-op. Implementación real pendiente.
}
}

@ -0,0 +1,168 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { VisualChannel } from './visual'
describe('VisualChannel', () => {
let target: HTMLElement
beforeEach(() => {
document.body.innerHTML = ''
target = document.createElement('div')
document.body.appendChild(target)
vi.useFakeTimers({ toFake: ['setTimeout'] })
})
afterEach(() => {
vi.useRealTimers()
})
it('writes the 5 attrs when family + intent come in the signal', () => {
const channel = new VisualChannel()
void channel.handle({
target,
name: 'announce',
family: 'alert',
intent: 'risk',
id: 'sig-test-1',
hold: 100
})
expect(target.getAttribute('data-event')).toBe('announce')
expect(target.getAttribute('data-event-id')).toBe('sig-test-1')
expect(target.getAttribute('data-event-phase')).toBe('active')
expect(target.getAttribute('data-event-family')).toBe('alert')
expect(target.getAttribute('data-intent')).toBe('risk')
})
it('writes only the 3 mandatory attrs when family/intent omitted', () => {
const channel = new VisualChannel()
void channel.handle({
target,
name: 'open',
id: 'sig-test-2',
hold: 100
})
expect(target.getAttribute('data-event')).toBe('open')
expect(target.getAttribute('data-event-id')).toBe('sig-test-2')
expect(target.getAttribute('data-event-phase')).toBe('active')
expect(target.hasAttribute('data-event-family')).toBe(false)
expect(target.hasAttribute('data-intent')).toBe(false)
})
it('respects signal.hold when explicitly provided', async () => {
const channel = new VisualChannel()
const promise = channel.handle({
target,
name: 'announce',
id: 'sig-1',
hold: 500
})
expect(target.getAttribute('data-event')).toBe('announce')
vi.advanceTimersByTime(499)
await Promise.resolve()
expect(target.getAttribute('data-event')).toBe('announce')
vi.advanceTimersByTime(1)
await promise
expect(target.hasAttribute('data-event')).toBe(false)
})
it('falls back to the family default when no explicit hold', async () => {
const channel = new VisualChannel()
// alert family default → 600ms (internal table)
const promise = channel.handle({
target,
name: 'announce',
family: 'alert',
id: 'sig-1'
})
vi.advanceTimersByTime(599)
await Promise.resolve()
expect(target.getAttribute('data-event')).toBe('announce')
vi.advanceTimersByTime(1)
await promise
expect(target.hasAttribute('data-event')).toBe(false)
})
it('falls back to the global default (240ms) when no hold and no family', async () => {
const channel = new VisualChannel()
const promise = channel.handle({
target,
name: 'open',
id: 'sig-1'
})
vi.advanceTimersByTime(239)
await Promise.resolve()
expect(target.getAttribute('data-event')).toBe('open')
vi.advanceTimersByTime(1)
await promise
expect(target.hasAttribute('data-event')).toBe(false)
})
it('honors the constructor-supplied defaultHold', async () => {
const channel = new VisualChannel({ defaultHold: 50 })
const promise = channel.handle({
target,
name: 'open',
id: 'sig-1'
})
vi.advanceTimersByTime(49)
await Promise.resolve()
expect(target.getAttribute('data-event')).toBe('open')
vi.advanceTimersByTime(1)
await promise
expect(target.hasAttribute('data-event')).toBe(false)
})
it('removes attrs after hold elapses', async () => {
const channel = new VisualChannel()
const promise = channel.handle({
target,
name: 'announce',
family: 'alert',
intent: 'risk',
id: 'sig-1',
hold: 100
})
vi.advanceTimersByTime(100)
await promise
expect(target.hasAttribute('data-event')).toBe(false)
expect(target.hasAttribute('data-event-id')).toBe(false)
expect(target.hasAttribute('data-event-phase')).toBe(false)
expect(target.hasAttribute('data-event-family')).toBe(false)
expect(target.hasAttribute('data-intent')).toBe(false)
})
it('Promise resolves AFTER cleanup (sequential strict)', async () => {
const channel = new VisualChannel()
let signalPresentAtResolve: boolean | null = null
const promise = channel
.handle({ target, name: 'announce', id: 'sig-1', hold: 100 })
.then(() => {
signalPresentAtResolve = target.hasAttribute('data-event')
})
vi.advanceTimersByTime(100)
await promise
expect(signalPresentAtResolve).toBe(false)
})
it('id field is "visual"', () => {
const channel = new VisualChannel()
expect(channel.id).toBe('visual')
})
})

@ -0,0 +1,106 @@
import type { SemanticSignal } from '../signal'
import type { Channel } from './types'
/**
* Canal visual — materializa señales semánticas como atributos `data-event*`
* en el DOM target del signal, los mantiene durante un `hold` configurado, y
* los retira antes de resolver la Promise (semántica secuencial estricta:
* el caller aplica el commit estructural cuando la señal ya ha terminado).
*
* Atributos escritos:
* - `data-event` (siempre) — nombre del evento
* - `data-event-id` (siempre) — id de la ocurrencia (lo genera el engine)
* - `data-event-phase` (siempre) — `'active'` mientras la señal vive
* - `data-event-family` (opcional) — solo si el signal trae `family`
* - `data-intent` (opcional) — solo si el signal trae `intent`
*
* Los dos opcionales son proyección al DOM de la metadata semántica que
* Eidos consume (`[data-intent="risk"]`, `[data-event-family="alert"]`).
*
* Es un detalle de este canal, no API pública del engine: si en el futuro
* otros canales (sound, vibra) quieren cosas distintas en sus respectivos
* planos, las definirán por separado.
*/
export interface VisualChannelOptions {
/**
* Default global de hold en ms cuando ni el signal ni la familia
* proporcionan uno. Default: 240 ms.
*
* El integrador puede subirlo si sus CSS transitions son más largas
* que el rango típico (150–300 ms para micro-interacciones,
* 200–400 ms para overlays).
*/
defaultHold?: number
}
/**
* Tabla interna de defaults `family → ms`. Refleja el tiempo razonable que
* las CSS transitions típicas necesitan para esa familia de eventos:
* - emerge / commit / handle: rango de micro-feedback (240 ms)
* - sustain / alert: rango de feedback sostenido / anuncio (600 ms)
* - contact: blink-and-miss aceptable (120 ms)
*
* No exportado — detalle interno del canal visual. La justificación es
* técnica (cobertura de CSS transitions), no perceptiva universal.
*/
const HOLD_DEFAULTS_BY_FAMILY: Record<string, number> = {
emerge: 240,
sustain: 600,
contact: 120,
commit: 240,
alert: 600,
handle: 240
}
const DEFAULT_HOLD_MS = 240
export class VisualChannel implements Channel {
readonly id = 'visual'
private readonly defaultHold: number
constructor(opts: VisualChannelOptions = {}) {
this.defaultHold = opts.defaultHold ?? DEFAULT_HOLD_MS
}
async handle(signal: SemanticSignal): Promise<void> {
const target = signal.target
if (!target) return
const id = signal.id ?? ''
// Escribir atributos
target.setAttribute('data-event', signal.name)
target.setAttribute('data-event-id', id)
target.setAttribute('data-event-phase', 'active')
if (signal.family) {
target.setAttribute('data-event-family', signal.family)
}
if (signal.intent) {
target.setAttribute('data-intent', signal.intent)
}
// Hold — cubre frame del compositor + duración perceptiva del feedback
const holdMs = this.resolveHoldMs(signal)
await sleep(holdMs)
// Cleanup antes de resolve (semántica secuencial estricta)
target.removeAttribute('data-event')
target.removeAttribute('data-event-id')
target.removeAttribute('data-event-phase')
if (signal.family) target.removeAttribute('data-event-family')
if (signal.intent) target.removeAttribute('data-intent')
}
private resolveHoldMs(signal: SemanticSignal): number {
if (typeof signal.hold === 'number') return signal.hold
if (signal.family && signal.family in HOLD_DEFAULTS_BY_FAMILY) {
return HOLD_DEFAULTS_BY_FAMILY[signal.family]
}
return this.defaultHold
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)))
}

@ -2,11 +2,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createActiveDom } from '$uix/adom'
import { SemanticEngine } from './engine'
import { SEMA_HOLD_DEFAULTS, SEMA_PERCEPTION } from './perception'
describe('SemanticEngine.emit', () => {
/**
* Integration tests of `engine.emit()` going through the engine + the
* built-in VisualChannel. Channel-level unit tests live in
* `chans/visual.test.ts`.
*/
describe('SemanticEngine.emit (integration with built-in VisualChannel)', () => {
let target: HTMLElement
beforeEach(() => {
@ -20,16 +23,8 @@ describe('SemanticEngine.emit', () => {
vi.useRealTimers()
})
it('throws when constructed without a dom', async () => {
const engine = new SemanticEngine()
await expect(
engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk' })
).rejects.toThrow(/requires `dom`/)
})
it('writes signal attrs synchronously', () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
const engine = new SemanticEngine()
void engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk' })
@ -41,8 +36,7 @@ describe('SemanticEngine.emit', () => {
})
it('keeps signal in DOM until the full hold elapses (sequential strict)', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
const engine = new SemanticEngine()
let resolved = false
const promise = engine.emit({ target, name: 'announce', hold: 500 }).then(() => {
@ -65,15 +59,14 @@ describe('SemanticEngine.emit', () => {
expect(resolved).toBe(true)
})
it('uses SEMA_HOLD_DEFAULTS for the resolved family when no explicit hold', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
it('uses the family default for hold when no explicit hold', async () => {
const engine = new SemanticEngine()
const expected = SEMA_HOLD_DEFAULTS.alert // 600ms (noticed)
// alert family default → 600ms (built into VisualChannel)
const promise = engine.emit({ target, name: 'announce', family: 'alert' })
expect(target.getAttribute('data-event')).toBe('announce')
vi.advanceTimersByTime(expected - 1)
vi.advanceTimersByTime(599)
await Promise.resolve()
expect(target.getAttribute('data-event')).toBe('announce')
@ -82,14 +75,13 @@ describe('SemanticEngine.emit', () => {
expect(target.hasAttribute('data-event')).toBe(false)
})
it('falls back to SEMA_PERCEPTION.brief when family is omitted', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
it('falls back to the global default (240ms) when no family and no hold', async () => {
const engine = new SemanticEngine()
const promise = engine.emit({ target, name: 'open' })
expect(target.getAttribute('data-event')).toBe('open')
vi.advanceTimersByTime(SEMA_PERCEPTION.brief - 1)
vi.advanceTimersByTime(239)
await Promise.resolve()
expect(target.getAttribute('data-event')).toBe('open')
@ -99,19 +91,17 @@ describe('SemanticEngine.emit', () => {
})
it('omits family/intent attrs when not provided', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
const engine = new SemanticEngine()
const promise = engine.emit({ target, name: 'open' })
expect(target.hasAttribute('data-event-family')).toBe(false)
expect(target.hasAttribute('data-intent')).toBe(false)
vi.advanceTimersByTime(SEMA_PERCEPTION.brief)
vi.advanceTimersByTime(240)
await promise
})
it('cleanup happens BEFORE the Promise resolves (sequential semantics)', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
const engine = new SemanticEngine()
let signalPresentAtResolve: boolean | null = null
const promise = engine.emit({ target, name: 'announce', hold: 100 }).then(() => {
@ -124,4 +114,12 @@ describe('SemanticEngine.emit', () => {
// will land AFTER the signal has been cleaned up.
expect(signalPresentAtResolve).toBe(false)
})
it('resolves immediately when the visual channel is disabled', async () => {
const engine = new SemanticEngine({ visual: false })
// No DOM mutation when visual channel isn't registered.
await engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk', hold: 9999 })
expect(target.hasAttribute('data-event')).toBe(false)
})
})

@ -1,114 +1,184 @@
import { describe, expect, it } from 'vitest'
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { SemanticEngine } from './engine'
import { dialogMorfo } from '../morfo/components/dialog'
import { toastMorfo } from '../morfo/components/toast'
function createElement(initial: Record<string, string> = {}): HTMLElement {
const attrs = new Map<string, string>(Object.entries(initial))
return {
getAttribute(name: string) {
return attrs.has(name) ? attrs.get(name)! : null
},
setAttribute(name: string, value: string) {
attrs.set(name, value)
},
removeAttribute(name: string) {
attrs.delete(name)
import type { Channel } from './chans/types'
import type { SemanticSignal } from './signal'
/**
* Tests del SemanticEngine como registry + dispatch. La materialización en
* el DOM (atributos + hold) la cubre `chans/visual.test.ts`. Aquí solo
* verificamos que el engine despacha correctamente a los canales.
*/
describe('SemanticEngine', () => {
function makeChannel(id: string, handle?: (signal: SemanticSignal) => Promise<void>): Channel {
return {
id,
handle: handle ?? vi.fn(async () => {})
}
} as unknown as HTMLElement
}
}
describe('SemanticEngine', () => {
it('publishes a dialog event with normalized canonical semantics', () => {
const engine = new SemanticEngine()
const contentEl = createElement({ 'data-state': 'open' })
function makeTarget(): HTMLElement {
return document.createElement('div')
}
const published = engine.publish(dialogMorfo, 'close-save', {
targetEl: contentEl,
partEls: { content: contentEl },
cause: 'pointer'
})
it('register adds a channel and getChannel returns it', () => {
const engine = new SemanticEngine({ visual: false })
const ch = makeChannel('sound')
engine.register(ch)
expect(engine.getChannel('sound')).toBe(ch)
})
expect(contentEl.getAttribute('data-last-action')).toBe('saved')
expect(published).toMatchObject({
name: 'close-save',
component: 'dialog',
target: 'content',
family: 'commit',
intent: 'fulfill',
label: 'commit-fulfill',
mode: 'blocking',
regime: 'lock',
scope: 'part',
cause: 'pointer',
prewritten: [{ part: 'content', attr: 'data-last-action', value: 'saved' }]
})
it('register throws on duplicate id', () => {
const engine = new SemanticEngine({ visual: false })
engine.register(makeChannel('vibra'))
expect(() => engine.register(makeChannel('vibra'))).toThrow(/already registered/)
})
it('resolves prop-driven intent from morfo events', () => {
const engine = new SemanticEngine()
const itemEl = createElement()
it('emit dispatches the signal to every registered channel', async () => {
const engine = new SemanticEngine({ visual: false })
const visualHandle = vi.fn(async () => {})
const soundHandle = vi.fn(async () => {})
const vibraHandle = vi.fn(async () => {})
const published = engine.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl },
props: { intent: 'risk' }
})
engine.register(makeChannel('visual', visualHandle))
engine.register(makeChannel('sound', soundHandle))
engine.register(makeChannel('vibra', vibraHandle))
expect(published).toMatchObject({
component: 'toast',
name: 'announce',
family: 'alert',
intent: 'risk',
label: 'alert-risk'
})
await engine.emit({ target: makeTarget(), name: 'announce' })
expect(visualHandle).toHaveBeenCalledTimes(1)
expect(soundHandle).toHaveBeenCalledTimes(1)
expect(vibraHandle).toHaveBeenCalledTimes(1)
})
it('falls back to the declared default intent when the prop is missing', () => {
const engine = new SemanticEngine()
const itemEl = createElement()
it('emit generates an id and propagates it to all channels', async () => {
const engine = new SemanticEngine({ visual: false })
const seen: SemanticSignal[] = []
const recordHandler = (s: SemanticSignal): Promise<void> => {
seen.push(s)
return Promise.resolve()
}
engine.register(makeChannel('visual', recordHandler))
engine.register(makeChannel('sound', recordHandler))
const published = engine.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl }
})
await engine.emit({ target: makeTarget(), name: 'announce' })
expect(published.label).toBe('alert-neutral')
expect(published.intent).toBe('neutral')
expect(seen).toHaveLength(2)
expect(seen[0].id).toBe(seen[1].id)
expect(seen[0].id).toMatch(/^sig-\d+$/)
})
it('notifies subscribers with optional filtering', () => {
const engine = new SemanticEngine()
const itemEl = createElement()
const seen: string[] = []
it('emit preserves a caller-supplied id', async () => {
const engine = new SemanticEngine({ visual: false })
let seenId: string | undefined
engine.register(
makeChannel('visual', async (s) => {
seenId = s.id
})
)
await engine.emit({ target: makeTarget(), name: 'announce', id: 'custom-id' })
const unsubscribe = engine.onEvent((event) => {
seen.push(event.label)
}, { component: 'toast', family: 'alert' })
expect(seenId).toBe('custom-id')
})
engine.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl },
props: { intent: 'threat' }
it('emit awaits ONLY the visual channel; others are fire-and-forget', async () => {
const order: string[] = []
const visualHandle = vi.fn(async () => {
await new Promise((r) => setTimeout(r, 0))
order.push('visual-done')
})
unsubscribe()
engine.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl },
props: { intent: 'affirm' }
const slowSoundHandle = vi.fn(async () => {
await new Promise((r) => setTimeout(r, 50))
order.push('sound-done')
})
expect(seen).toEqual(['alert-threat'])
const engine = new SemanticEngine({ visual: false })
engine.register(makeChannel('visual', visualHandle))
engine.register(makeChannel('sound', slowSoundHandle))
await engine.emit({ target: makeTarget(), name: 'announce' })
order.push('emit-resolved')
// emit resolves after visual completes; sound may still be running.
expect(order[0]).toBe('visual-done')
expect(order[1]).toBe('emit-resolved')
// sound-done arrives later (not awaited) — give it a tick to land for cleanup.
await new Promise((r) => setTimeout(r, 60))
})
it('a channel error does not abort dispatch to other channels', async () => {
const engine = new SemanticEngine({ visual: false })
const visualHandle = vi.fn(async () => {})
const failingSound = vi.fn(async () => {
throw new Error('audio unavailable')
})
const vibraHandle = vi.fn(async () => {})
engine.register(makeChannel('visual', visualHandle))
engine.register(makeChannel('sound', failingSound))
engine.register(makeChannel('vibra', vibraHandle))
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
await engine.emit({ target: makeTarget(), name: 'announce' })
expect(visualHandle).toHaveBeenCalled()
expect(failingSound).toHaveBeenCalled()
expect(vibraHandle).toHaveBeenCalled()
// Give the rejected promise time to be observed by the .catch.
await new Promise((r) => setTimeout(r, 0))
expect(errSpy).toHaveBeenCalled()
} finally {
errSpy.mockRestore()
}
})
it('emit resolves immediately when no visual channel is registered', async () => {
const engine = new SemanticEngine({ visual: false })
const target = makeTarget()
// No visual, no sound, no vibra. emit resolves immediately and the
// target receives no attribute writes.
await engine.emit({ target, name: 'announce' })
expect(target.hasAttribute('data-event')).toBe(false)
})
it('throws when publishing an undeclared event', () => {
it('built-in VisualChannel is registered automatically when visual is omitted', () => {
const engine = new SemanticEngine()
expect(engine.getChannel('visual')).toBeDefined()
})
expect(() =>
engine.publish(toastMorfo, 'missing', {
targetEl: createElement()
})
).toThrow(/event "missing" not declared/)
it('visual: false skips the built-in VisualChannel', () => {
const engine = new SemanticEngine({ visual: false })
expect(engine.getChannel('visual')).toBeUndefined()
})
it('visual accepts options and applies them to the built-in channel', () => {
const engine = new SemanticEngine({ visual: { defaultHold: 999 } })
expect(engine.getChannel('visual')).toBeDefined()
})
it('visual accepts a custom Channel instance and replaces the built-in', () => {
const custom = makeChannel('visual')
const engine = new SemanticEngine({ visual: custom })
expect(engine.getChannel('visual')).toBe(custom)
})
it('destroy disposes channels and clears the registry', () => {
const dispose = vi.fn()
const ch: Channel = {
id: 'sound',
handle: async () => {},
dispose
}
const engine = new SemanticEngine({ visual: false })
engine.register(ch)
engine.destroy()
expect(dispose).toHaveBeenCalledTimes(1)
expect(engine.getChannel('sound')).toBeUndefined()
})
})

@ -1,285 +1,136 @@
import { DEV } from 'esm-env'
/**
* SemanticEngine — registry de canales perceptivos + dispatch.
*
* El engine no conoce DOM, ni atributos, ni hold. Su trabajo es:
* - mantener un registro de canales que implementan `Channel`
* - generar el `id` de cada ocurrencia
* - despachar cada signal a todos los canales registrados
* - bloquear al caller solo el tiempo que el canal visual necesite
* (los demás canales son fire-and-forget)
*
* El comportamiento que antes ejecutaba `emit()` directamente (escribir
* `data-event*` al DOM, esperar hold, retirar) vive ahora en `VisualChannel`
* y el engine lo invoca como uno más entre canales registrados.
*/
import { normalizeSemaEvent } from './event'
import { SEMA_HOLD_DEFAULTS, SEMA_PERCEPTION } from './perception'
import type { Channel } from './chans/types'
import { VisualChannel, type VisualChannelOptions } from './chans/visual'
import type { SemanticSignal } from './signal'
import type {
SemaAttrWrite,
SemaCause,
SemaCommit,
SemaEvent,
SemaEventLabel,
SemaFamily,
SemaIntent,
SemaMode,
SemaRegime,
SemaScope
} from './types'
import type { ActiveDom } from '$uix/adom'
import type { PartRef } from '../lib/types'
export interface SemanticEngineOpts {
/**
* DOM service injected by construction. Required for `emit()`. The legacy
* `publish()` path still works without it for backward compatibility while
* MorfoRuntime is being rolled out.
* Configuración del canal visual built-in.
*
* - omitido o `undefined` → VisualChannel built-in con defaults
* - `false` → no se inicializa el canal visual (caso edge: app
* audio-only, tests sin DOM, server-side rendering puro)
* - objeto con opciones → VisualChannel built-in con esas opciones
* - instancia de Channel → reemplaza al VisualChannel built-in
* (caso muy raro)
*/
dom?: ActiveDom
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)))
}
function buildSignalAttrs(signal: SemanticSignal, id: string): Record<string, string> {
const attrs: Record<string, string> = {
'data-event': signal.name,
'data-event-id': id,
'data-event-phase': 'active'
}
if (signal.family) attrs['data-event-family'] = signal.family
if (signal.intent) attrs['data-intent'] = signal.intent
return attrs
}
/**
* Resolves the signal's hold duration in milliseconds. Priority:
* 1. explicit `signal.hold` (the author's override)
* 2. `SEMA_HOLD_DEFAULTS[family]` (perceptual default per family)
* 3. `SEMA_PERCEPTION.brief` (240 ms) — fallback when family is omitted
*/
function resolveHoldMs(signal: SemanticSignal): number {
if (typeof signal.hold === 'number') return signal.hold
if (signal.family && signal.family in SEMA_HOLD_DEFAULTS) {
return SEMA_HOLD_DEFAULTS[signal.family as keyof typeof SEMA_HOLD_DEFAULTS]
}
return SEMA_PERCEPTION.brief
}
export interface SemanticEventDecl {
name: string
target: PartRef
semantic: SemaEvent
mode?: SemaMode
regime?: SemaRegime
scope?: SemaScope
prewrite?: readonly SemaAttrWrite[]
commits?: SemaCommit
}
export interface SemanticComponentContract {
kebab: string
events?: readonly SemanticEventDecl[]
}
export interface SemanticPublishContext {
targetEl: HTMLElement
rootEl?: HTMLElement
partEls?: Partial<Record<string, HTMLElement>>
props?: Record<string, unknown>
cause?: SemaCause
detail?: Record<string, unknown>
}
export interface PublishedSemanticEvent {
id: string
name: string
component: string
target: string
targetEl: HTMLElement
rootEl?: HTMLElement
family: SemaFamily
intent: SemaIntent | null
label: SemaEventLabel
mode: SemaMode
regime: SemaRegime
scope: SemaScope
prewritten: readonly { part: string; attr: string; value: string }[]
commits?: SemaCommit
cause?: SemaCause
detail?: Record<string, unknown>
timestamp: number
}
export interface SemanticEventFilter {
component?: string
name?: string
family?: SemaFamily
intent?: SemaIntent | null
}
type SemanticListener = (event: PublishedSemanticEvent) => void
interface SemanticSubscriber {
filter?: SemanticEventFilter
listener: SemanticListener
}
function matchesFilter(event: PublishedSemanticEvent, filter?: SemanticEventFilter): boolean {
if (!filter) return true
if (filter.component && filter.component !== event.component) return false
if (filter.name && filter.name !== event.name) return false
if (filter.family && filter.family !== event.family) return false
if ('intent' in filter && filter.intent !== event.intent) return false
return true
}
function findEvent(contract: SemanticComponentContract, name: string): SemanticEventDecl | undefined {
return contract.events?.find((event) => event.name === name)
}
function resolvePartElement(
ctx: SemanticPublishContext,
targetPart: string,
fallback: HTMLElement
): HTMLElement | undefined {
return ctx.partEls?.[targetPart] ?? fallback
visual?: false | VisualChannelOptions | Channel
}
export class SemanticEngine {
private readonly subscribers = new Set<SemanticSubscriber>()
private readonly dom: ActiveDom | undefined
private nextId = 0
private readonly channels = new Map<string, Channel>()
private nextSignalId = 0
constructor(opts: SemanticEngineOpts = {}) {
this.dom = opts.dom
// VisualChannel built-in automático salvo desactivación explícita.
if (opts.visual !== false) {
const visualChannel: Channel = isChannel(opts.visual)
? opts.visual
: new VisualChannel(opts.visual ?? {})
this.register(visualChannel)
}
}
/**
* Emit a perceptual signal. **Sequential strict semantics**: the
* Promise resolves only after the full `hold` window has elapsed AND
* the signal has been cleaned up. The caller's subsequent structural
* change therefore lands AFTER the user has had time to perceive the
* signal — there is no parallelism between event and state change.
* Registra un canal. Los canales no built-in (sound, vibra, futuros)
* se registran explícitamente desde el bootstrap de la app.
*
* Sequence:
* 1. write `data-event*` via `dom.apply`
* 2. await `hold` ms total — covers the rAF compositor boundary + any
* CSS animation duration + perceptual hold the author specified
* 3. clear `data-event*` via `dom.remove`
* 4. resolve the Promise (caller now runs handler → state change)
*
* Duration selection (the only timing knob the author touches):
* - With CSS animation: `hold` >= animation duration so the rule
* stays matched until the animation completes.
* - Without animation, perceptual minimum: `SEMA_PERCEPTION.brief`.
* - Without animation, attention-demanding: `SEMA_PERCEPTION.noticed`
* or `insistent`.
*
* Falls back to `SEMA_HOLD_DEFAULTS[family]` then to
* `SEMA_PERCEPTION.brief` (240 ms) when neither override nor family is
* available.
* Lanza si ya hay un canal con el mismo `id`.
*/
async emit(signal: SemanticSignal): Promise<void> {
if (!this.dom) {
register(channel: Channel): void {
if (this.channels.has(channel.id)) {
throw new Error(
'[semantic] SemanticEngine.emit requires `dom` to be passed in the constructor.'
`[semantic] channel with id "${channel.id}" already registered`
)
}
const id = `sig-${this.nextSignalId++}`
const attrs = buildSignalAttrs(signal, id)
const dom = this.dom
dom.apply({ target: signal.target, attrs })
const holdMs = resolveHoldMs(signal)
await sleep(holdMs)
const names = Object.keys(attrs)
dom.remove(signal.target, names)
this.channels.set(channel.id, channel)
}
publish(
contract: SemanticComponentContract,
name: string,
ctx: SemanticPublishContext
): PublishedSemanticEvent {
const decl = findEvent(contract, name)
if (!decl) {
throw new Error(
`[semantic] event "${name}" not declared in "${contract.kebab}". Declared events: ${contract.events?.map((event) => event.name).join(', ') || '∅'}`
)
/**
* Despacha una señal a todos los canales registrados.
*
* Genera el `id` de la ocurrencia si la señal no lo trae — la identidad
* de la ocurrencia debe ser consistente entre canales (un futuro tooling
* que cruce eventos del DOM con eventos del SoundEngine espera ids
* coincidentes; generar en cada canal por separado rompería esto).
*
* Devuelve una Promise que resuelve cuando el canal visual ha completado
* su materialización (semántica secuencial estricta: la Promise resuelve
* tras escribir + hold + retirar). Los demás canales son fire-and-forget
* y no afectan al timing — solo el visual comparte plano DOM con el
* commit estructural posterior.
*
* Si el canal visual no está registrado, la Promise resuelve inmediatamente.
*
* Sema es ornamental: errores en canales no-visuales se loguean pero no
* propagan, así un fallo en audio context o vibration API no aborta la
* operación del provider.
*/
async emit(signal: SemanticSignal): Promise<void> {
const enriched: SemanticSignal = {
...signal,
id: signal.id ?? `sig-${this.nextSignalId++}`
}
const targetEl = resolvePartElement(ctx, decl.target.target, ctx.targetEl)
if (!targetEl) {
throw new Error(
`[semantic] target part "${decl.target.target}" has no runtime element in "${contract.kebab}.${name}".`
)
const visualChannel = this.channels.get('visual')
const otherChannels: Channel[] = []
for (const channel of this.channels.values()) {
if (channel.id !== 'visual') otherChannels.push(channel)
}
const prewritten = this.applyPrewrites(contract, decl, ctx, targetEl)
const normalized = normalizeSemaEvent(decl.semantic, ctx.props)
const published: PublishedSemanticEvent = {
id: `sem-${this.nextId++}`,
name: decl.name,
component: contract.kebab,
target: decl.target.target,
targetEl,
rootEl: ctx.rootEl,
family: normalized.family,
intent: normalized.intent,
label: normalized.label,
mode: decl.mode ?? 'blocking',
regime: decl.regime ?? 'replace',
scope: decl.scope ?? 'part',
prewritten,
commits: decl.commits,
cause: ctx.cause,
detail: ctx.detail,
timestamp: Date.now()
// Fire-and-forget para canales no-visuales.
for (const channel of otherChannels) {
channel.handle(enriched).catch((err) => {
console.error(`[semantic] channel "${channel.id}" failed:`, err)
})
}
for (const subscriber of this.subscribers) {
if (!matchesFilter(published, subscriber.filter)) continue
subscriber.listener(published)
// El canal visual sí afecta al timing del caller.
if (visualChannel) {
await visualChannel.handle(enriched)
}
return published
}
onEvent(listener: SemanticListener, filter?: SemanticEventFilter): () => void {
const subscriber: SemanticSubscriber = { listener, filter }
this.subscribers.add(subscriber)
return () => {
this.subscribers.delete(subscriber)
}
/**
* Devuelve un canal registrado por id, o `undefined`.
* Para introspección y testing.
*/
getChannel(id: string): Channel | undefined {
return this.channels.get(id)
}
/**
* Libera recursos de todos los canales y limpia el registry.
* Llamar al desmontar la aplicación si los canales tienen estado externo
* (un `AudioContext`, listeners, timers).
*/
destroy(): void {
this.subscribers.clear()
}
private applyPrewrites(
contract: SemanticComponentContract,
decl: SemanticEventDecl,
ctx: SemanticPublishContext,
targetEl: HTMLElement
): readonly { part: string; attr: string; value: string }[] {
const applied: Array<{ part: string; attr: string; value: string }> = []
for (const write of decl.prewrite ?? []) {
const el = resolvePartElement(ctx, write.part.target, write.part.target === decl.target.target ? targetEl : ctx.targetEl)
if (!el) {
if (DEV) {
console.warn(
`[semantic] prewrite target "${write.part.target}" missing for "${contract.kebab}.${decl.name}".`
)
}
continue
}
el.setAttribute(write.attr, write.value)
applied.push({
part: write.part.target,
attr: write.attr,
value: write.value
})
for (const channel of this.channels.values()) {
channel.dispose?.()
}
return applied
this.channels.clear()
}
}
function isChannel(value: unknown): value is Channel {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as Channel).id === 'string' &&
typeof (value as Channel).handle === 'function'
)
}

@ -33,25 +33,16 @@ export {
toSemaEventLabel
} from './event'
export type {
SemanticEventDecl,
SemanticComponentContract,
SemanticPublishContext,
PublishedSemanticEvent,
SemanticEventFilter,
SemanticEngineOpts
} from './engine'
export type { SemanticEngineOpts } from './engine'
export { SemanticEngine } from './engine'
export type { SemanticSignal } from './signal'
export {
SEMA_PERCEPTION,
SEMA_HOLD_DEFAULTS,
SEMA_OBSERVE_FRAME_MS,
type SemaPerception
} from './perception'
// ── Channels ───────────────────────────────────────────────────────────────
export type { Channel } from './chans/types'
export { VisualChannel, type VisualChannelOptions } from './chans/visual'
export { SoundChannel } from './chans/sound'
export { VibraChannel } from './chans/vibra'
export {
SEMA_VERBS,

@ -1,74 +0,0 @@
/**
* Sema — perceptual time scales.
*
* The lifetime of a perceptual signal in the DOM is anchored on **human
* visual perception**, not on frame counts. These constants define the
* named windows the engine uses; concrete event types pick the one that
* matches the signal's intent.
*
* Sources / reasoning:
* - Bloch's law: stimuli under ~100 ms are integrated as a single instant
* (no perception of duration). Anything below that is sub-conscious.
* - Material Design / Apple HIG motion: 200–250 ms is the comfort floor
* for "noticed but quick" feedback.
* - User studies on flash perception: ~600 ms is the comfort ceiling for
* a non-disruptive announcement; longer reads as "demands attention".
* - Toast / banner UX literature: 1.2–3.0 s is the typical persistence
* range for non-blocking notifications.
*
* Tooling that subscribes to sema (sound, vibra, screen-reader announcers)
* also benefits from perceptually-anchored windows: a 32 ms signal is
* unobservable to any subscriber that polls the DOM.
*/
export const SEMA_PERCEPTION = {
/** ~50 ms. Below conscious threshold; integrated as one instant. */
subliminal: 50,
/** ~120 ms. Minimum to be consciously noticed without effort. */
glimpse: 120,
/** ~240 ms. Comfortable brief acknowledgement (button press, micro-feedback). */
brief: 240,
/** ~600 ms. Sustained visible signal (announce pulse). */
noticed: 600,
/** ~1200 ms. Demands attention (warnings, errors). */
insistent: 1200,
/** ~3000 ms. Persistent until acknowledged or auto-dismissed. */
persistent: 3000
} as const;
export type SemaPerception = keyof typeof SEMA_PERCEPTION;
/**
* Default `hold` (ms) per semantic family, mapped onto the perception scale.
*
* `hold` controls how long the signal's `data-event*` attrs stay in the DOM
* **after** the engine resolves its Promise. Picked so that:
* - CSS animations triggered by the signal can finish observing it.
* - Screen-reader announcers + sound/vibra subscribers have time to react.
* - Users with slow refresh devices or extended-vision tools can perceive
* the signal directly if styled to do so.
*
* Authors override per-event via `SemanticSignal.hold` when the default
* doesn't fit (e.g. a `dismiss` that wants to be `subliminal`, or a
* `threat` that should stay `insistent`).
*/
export const SEMA_HOLD_DEFAULTS = {
// Transitional families — short, just enough for CSS to latch + animate.
emerge: SEMA_PERCEPTION.brief,
sustain: SEMA_PERCEPTION.noticed,
// Valenced families — anchored to the kind of attention they ask for.
contact: SEMA_PERCEPTION.glimpse, // first interaction, blink-and-miss is fine
commit: SEMA_PERCEPTION.brief, // confirmation feedback
alert: SEMA_PERCEPTION.noticed, // announcements
handle: SEMA_PERCEPTION.brief // sustained interaction marker
} as const;
/**
* The compositor frame budget — used by the engine to decide how long to
* `await` between writing the signal and resolving the Promise. A single
* rAF (~16 ms at 60 Hz) is enough for CSS to start any rule keyed off the
* new attrs. This is the only timing in the engine that's not perceptual:
* it's a strict hardware/render boundary.
*/
export const SEMA_OBSERVE_FRAME_MS = 16;

@ -1,42 +1,48 @@
/**
* SemanticSignal — runtime payload that the provider (or MorfoRuntime) hands
* to `EngineSemantic.emit(...)`. Distinct from `SemaEvent`, which describes
* the *declaration* of an event in a morfo file (`{ family, intent }`).
* SemanticSignal — runtime payload que el provider (o MorfoRuntime) entrega
* a `SemanticEngine.emit(...)`. Distinto de `SemaEvent`, que describe la
* **declaración** de un evento en un fichero morfo (`{ family, intent }`).
*
* `SemanticSignal` describes the actual occurrence: a target, a name, the
* resolved family/intent, and an optional hold duration **in milliseconds,
* anchored to perceptual scales** (see `perception.ts`).
* Channel-agnostic: la misma señal se despacha a cada canal registrado
* (visual, sound, vibra, futuros). Cada canal materializa la señal en su
* propia modalidad.
*/
import type { SemaFamily, SemaIntent } from './types'
export interface SemanticSignal {
/** DOM target where the perceptual signal is reflected. */
/** DOM target donde se proyecta la señal en el canal visual. */
target: HTMLElement
/** Event name as declared in `morfo.events[].name` (e.g. `'close-cancel'`). */
/** Nombre del evento, alineado con `morfo.events[].name` (e.g. `'close-cancel'`). */
name: string
/** Resolved intent for valenced families. Omit for transitional families. */
/** Intent resuelto para familias valenced. Omitir para familias transitional. */
intent?: SemaIntent
/** Resolved family. When omitted, only `data-event` and `data-event-id` are written. */
/** Familia semántica del signal. */
family?: SemaFamily
/**
* Hold duration in **milliseconds** — how long the signal's `data-event*`
* attrs remain in the DOM after the engine resolves its Promise.
* Hold duration en milisegundos — cuánto tiempo los atributos `data-event*`
* permanecen en el DOM antes de que el VisualChannel resuelva su Promise.
*
* Defaults from `SEMA_HOLD_DEFAULTS[family]` when omitted, falling back
* to `SEMA_PERCEPTION.brief` (240 ms). Authors override per-event with
* named perception scales:
* Si se omite, el VisualChannel resuelve usando su default por familia
* (ver `VisualChannel`). Si la familia tampoco tiene default, usa el
* default global del VisualChannel (240 ms).
*
* ```ts
* import { SEMA_PERCEPTION } from '$uix/sema'
* runtime.trigger('announce') // uses default for family 'alert' → 600ms
* // override at trigger site if needed:
* semantic.emit({ ..., hold: SEMA_PERCEPTION.insistent }) // 1200ms
* ```
* Override por signal cuando el caso lo justifica (animación CSS larga,
* señal que debe ser más insistente, etc.).
*
* Otros canales (sound, vibra) ignoran este campo — gestionan su propio
* timing en sus respectivos planos.
*/
hold?: number
/**
* Id de la ocurrencia. Opcional al construir — el engine lo genera si no
* se proporciona. Caso de uso típico para pasarlo: tooling que correlaciona
* signals con eventos externos.
*/
id?: string
}

Loading…
Cancel
Save

Powered by TurnKey Linux.