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
parent
e664b59c96
commit
5871cbca6e
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@ -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…
Reference in new issue