/** * EngineSemantic — registry de canales perceptivos + dispatch. * * El engine no conoce DOM, ni hold, ni atributos de estado. Su trabajo es: * * 1. Asignar un id estable a cada ocurrencia. * 2. Ejecutar `channel.prepare(...)` antes de la cascade para que los * canales publiquen su superficie temporal si la necesitan. * 3. Resolver la `EffectiveSignature` con las 6 capas de cascade. * 4. Despachar a cada canal registrado. * 5. Esperar al canal visual y limpiar los handles de preparación. * * Override layers (ver `resolver.ts`): * 1. family base — SEMA_MAP.families * 2. intent deltas — SEMA_MAP.intents (valenced only) * 3. soundPack URL — SEMA_MAP.soundPack * 4. morfo per-event — signal.overrides + signal.channels * 5. runtime path overrides — engineOpts.overrides.runtime * (applied to map at construction) * 6. cascade rules — engineOpts.components (per-component * packs prepended) + engineOpts.overrides.cascade * (app-level rules appended; win on tie) */ import type { Channel, ChannelPreparation } from './chans/types'; import type { DomApplier } from '$libs/dom'; import { HapticChannel, type HapticChannelOptions } from './chans/haptic'; import { SoundChannel, type SoundChannelOptions } from './chans/sound'; import { VisualChannel, type VisualChannelOptions } from './chans/visual'; import { SemaDuplicateChannelError } from './errors'; import { applyMapOverrides, resolveSignature, type SemaCascadeRule } from './resolver'; import { SEMA_MAP, type DeltaValue, type SemaMap, type Sema } from './sema-map'; import type { SemanticSignal } from './signal'; import type { SignalProjector } from './projection'; export interface EngineSemanticOverrides { /** * Path-based mutations of the SEMA_MAP applied at construction. The * canonical map is not mutated; only the overridden object path is cloned. */ runtime?: Record; /** * App-level cascade rules (CSS-style). Concatenated AFTER all * per-component packs so app rules win on tie (declaration order). */ cascade?: readonly SemaCascadeRule[]; } export interface EngineSemanticOptions { /** VisualChannel built-in. `false` to disable; object for opts; Channel to replace. */ visual?: false | VisualChannelOptions | Channel; /** SoundChannel built-in. `true`/object/Channel to enable; `false`/undef to skip. */ sound?: true | false | SoundChannelOptions | Channel; /** HapticChannel built-in. `true`/object/Channel to enable; `false`/undef to skip. */ haptic?: true | false | HapticChannelOptions | Channel; /** * Per-component packs of perceptual defaults. Each pack ships with * the component (`src/uix/sema/components/{name}.ts`) and is * concatenated into the cascade BEFORE `overrides.cascade`. Tree- * shakable: only packs the app imports get included. */ components?: readonly Sema[]; /** Override layers 5 (runtime path) + 6b (app cascade). */ overrides?: EngineSemanticOverrides; /** * Projector forwarded to the built-in VisualChannel. The engine stores no * projector itself; channels own their prepare/materialization details. */ projector?: SignalProjector; /** * DOM writer forwarded to the built-in VisualChannel's default projector. * In `active-uix` this is `uix.dom`, so sema projects event attrs through * the same DOM owner as soma. */ dom?: DomApplier; } export class EngineSemantic { private readonly channels = new Map(); private nextSignalId = 0; private readonly map: SemaMap; private readonly cascade: readonly SemaCascadeRule[]; constructor(opts: EngineSemanticOptions = {}) { // Layer 5 — runtime path overrides baked into the map at construction. this.map = applyMapOverrides(SEMA_MAP, opts.overrides?.runtime); // Layer 6 — flatten per-component packs (lower precedence) + // app-level cascade (higher precedence on tie). const packCascade: SemaCascadeRule[] = []; const preloadUrls = new Set(); for (const pack of opts.components ?? []) { for (const rule of pack.cascade) { packCascade.push(rule as SemaCascadeRule); } for (const url of pack.preloadSamples ?? []) { preloadUrls.add(url); } } this.cascade = [...packCascade, ...(opts.overrides?.cascade ?? [])]; // Channels. if (opts.visual !== false) { const visualChannel: Channel = isChannel(opts.visual) ? opts.visual : new VisualChannel({ ...(opts.visual ?? {}), dom: opts.dom, projector: opts.projector }); this.register(visualChannel); } if (opts.sound !== undefined && opts.sound !== false) { const soundChannel: Channel = isChannel(opts.sound) ? opts.sound : new SoundChannel(opts.sound === true ? {} : opts.sound); this.register(soundChannel); // Pre-decode the WAVs declared by component packs so the first // emit doesn't pay the fetch + decode latency. if (preloadUrls.size > 0 && soundChannel instanceof SoundChannel) { void soundChannel.preloadSamples([...preloadUrls]); } } if (opts.haptic !== undefined && opts.haptic !== false) { const hapticChannel: Channel = isChannel(opts.haptic) ? opts.haptic : new HapticChannel(opts.haptic === true ? {} : opts.haptic); this.register(hapticChannel); } } register(channel: Channel): void { if (this.channels.has(channel.id)) { throw new SemaDuplicateChannelError(channel.id); } this.channels.set(channel.id, channel); } /** * Despacha una señal a todos los canales registrados. * * Lifecycle: * 1. Generate id and run channel prepare hooks. * VisualChannel.prepare() projects `data-event-*` on target. * 2. resolveSignature → cascade selectors match against the now- * projected target via native `target.matches()`. * 3. Fire-and-forget non-visual channels. * 4. Await visual channel hold. * 5. Cleanup prepare handles. * * Sequential strict: the caller's structural commit happens AFTER * cleanup. State change is strictly ordered after the perceptual window. */ async emit(signal: SemanticSignal): Promise { const enriched: SemanticSignal = { ...signal, id: signal.id ?? `sig-${this.nextSignalId++}` }; // Explicit silence (capa 4 morfo override): `signal.channels: []` // skips EVERYTHING — no projection, no dispatch, no hold. The morfo // declared this event has no perceptual surface. Other paths to an // empty `effective.activeChannels` (e.g. signal with no family — // degenerate case) still go through dispatch so individual channels // can self-skip via their own activeChannels check. if (signal.channels !== undefined && signal.channels.length === 0) { return; } const preparations: ChannelPreparation[] = []; for (const channel of this.channels.values()) { const handle = channel.prepare?.(enriched); if (handle) preparations.push(handle); } try { const effective = resolveSignature(enriched, { map: this.map, cascade: this.cascade }); const visualChannel = this.channels.get('visual'); const otherChannels: Channel[] = []; for (const channel of this.channels.values()) { if (channel.id !== 'visual') otherChannels.push(channel); } for (const channel of otherChannels) { channel.handle(enriched, effective).catch((err) => { console.error(`[semantic] channel "${channel.id}" failed:`, err); }); } // Visual owns the hold timing. It runs whenever there are ANY // active channels — the empty-channels case was already handled // by the early return above. Visual isn't in family activeChannels // (those describe real runtime channels like sound / haptic) — // it's a meta channel that gates the data-event-* hold for eidos // CSS reactions. if (visualChannel) { await visualChannel.handle(enriched, effective); } } finally { for (const handle of preparations.reverse()) { handle.cleanup(); } } } getChannel(id: string): Channel | undefined { return this.channels.get(id); } dispose(): void { for (const channel of this.channels.values()) { channel.dispose?.(); } 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' ); }