/** * 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 5 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 * 1.5 the SOUND — the catalogue name selected by the morfo * event and the cascade (sound-names.ts), * applied BEFORE the intent so the * evaluative profile always survives it * 2. intent deltas — SEMA_MAP.intents (any family with intent) * 3. morfo per-event — signal.overrides + signal.channels * 4. runtime path overrides — engineOpts.overrides.runtime * (applied to map at construction) * 5a. pack cascade — engineOpts.components (per-component * packs, prepended) * 5b. app cascade — engineOpts.overrides.cascade * (app-level rules appended; win on tie) */ import type { Channel, ChannelPreparation } from './chans/types'; import type { DomApplier } from '$adom'; import type { Logger } from '$libs/logger'; import { HapticChannel, type HapticChannelDom, type HapticChannelOptions } from './chans/haptic'; import { SoundChannel, type SoundChannelDom, type SoundChannelOptions } from './chans/sound'; import type { EngineSound } from '$sound'; import { VisualChannel, type VisualChannelOptions } from './chans/visual'; import { AnnounceChannel, type AnnounceChannelDom, type AnnounceChannelOptions } from './chans/announce'; import { SemaDuplicateChannelError } from './errors'; import { namedSampleUrls, type SoundName } from './sound-names'; import type { NamedSound } from './sema-map'; import { applyMapOverrides, assertMapPaths, resolveSignature, type EffectiveSignature, type SemaCascadeRule } from './resolver'; import { SEMA_MAP, type SemaMap, type Sema } from './sema-map'; import type { DeltaValue, SemaPreferences } from './channels'; import type { SemanticSignal } from './signal'; import type { SignalProjector } from './projection'; import { semaDelay, type SemaTimerScheduler, type SemaTimerHandle } from './timers'; 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; /** * The shared Web Audio runtime (`$sound`), injected by the composition root * as `uix.sound`. Forwarded to the built-in `SoundChannel` so the whole * document meets at ONE `AudioContext`: the browser caps them and the * autoplay unlock gesture is per-context, so a second engine would leave * one of the two mute. The engine is NOT owned here — whoever created it * disposes it (`ownsSound` in `active-uix`). When absent, the channel * creates and owns a private one (direct construction / unit tests). */ soundEngine?: EngineSound; /** HapticChannel built-in. `true`/object/Channel to enable; `false`/undef to skip. */ haptic?: true | false | HapticChannelOptions | Channel; /** * AnnounceChannel built-in (accessible ARIA live region). `true`/object to * enable; `false`/undef to skip. Opt-in like sound / haptic. Pass * `{ announce }` to delegate to an existing announcer (e.g. * `ActiveUix.announce`); otherwise it owns a pair of live regions built from * the injected `dom`. Book `BK-SIGNAL-A11Y` / `BK-A11Y-CRITICAL`. */ announce?: true | false | AnnounceChannelOptions | Channel; /** * Optional diagnostics logger. When omitted, channel failures stay * silent — a deliberate purity rule: the engine knows no platform, so it * never falls back to `console` (the layer contract guards this). In * practice the failures are NOT silent in apps: the ActiveUix * composition root always injects its logger * (`active-uix.svelte.ts` — `logger: eventsOpts.logger ?? logger`), so a * silent engine only exists in bare constructions (unit tests / * headless), by choice (SEM-2 adjudication, 2026-07-11). */ logger?: Logger; /** * 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[]; /** * The SOUND PACK this app speaks with — entries layered over the framework's * own, by name. This is the boot-time form of {@link EngineSemantic.applySoundPack}; * pass it here when the voice is a property of the product rather than * something a theme switches at runtime. * * A partial pack is legitimate: names it omits keep the default sound, so a * product can replace three earcons and inherit the rest. * * ```ts * createActiveUix({ events: { sound: true, sounds: uiMp3Pack } }) * ``` */ sounds?: Partial>; /** Override layers 4 (runtime path) + 5b (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 | (DomApplier & SoundChannelDom); /** * Managed timer scheduler (`uix.timers`) forwarded to the built-in * channels. Every perceptual timer — the visual hold, the haptic delay, * the sound earcon completion — runs on it instead of a raw `setTimeout`, * so timing is cancellable, observable and test-deterministic. `active-uix` * injects `uix.timers`; omitting it makes the channels fall back to * `setTimeout` (unit-test / degraded path only). */ timers?: SemaTimerScheduler; /** * Per-channel reduction preferences (BK-REDUCTIONS, book ch. 32 §12 / ch. * 33 §7). Forwarded to the built-in `sound` + `haptic` channels so an app * can attenuate (`'reduce'`) or silence (`'off'`) a modality symmetrically. * Read at dispatch, so backing it with reactive state makes it live. * Visual-motion reduction lives in eidos CSS, not here. */ preferences?: SemaPreferences; /** * Frequency memory (BK-FREQ-MEMORY, book ch. 32 §11): repeated occurrences * of the SAME event attenuate their non-visual channels so the 50th * autosave does not sound like the first. `threat` is never damped. Default * ON; set `false` to disable. Requires a `timers` scheduler for the decay * window (falls back to a raw timeout in unit tests). */ frequencyMemory?: boolean; /** * Dominance arbiter (BK-DOMINANCE, book ch. 30 §7): when signals overlap in * time, a signal dominated by a still-active one (evaluable > structural; * higher activation; recency) has its non-visual channels muted while its * visual hold survives. `threat` is never muted. Default ON; set `false` to * disable. */ dominance?: boolean; } interface PersistentSignalEntry { readonly preparations: readonly ChannelPreparation[]; readonly target: HTMLElement | undefined; } // ── Frequency memory (C-2) + dominance (C-3) tuning ─────────────────────────── /** Non-threat signals of the same key start attenuating after this count. */ const FREQ_THRESHOLD = 2; /** Attenuation lost per repeat past the threshold. */ const FREQ_STEP = 0.15; /** Attenuation floor — repeated signals never fall below this fraction. */ const FREQ_FLOOR = 0.25; /** Window of silence (ms) after which a key's repeat count resets to full. */ const FREQ_WINDOW_MS = 2000; /** How long (ms) an occurrence counts as "active" for dominance overlap. */ const DOMINANCE_WINDOW_MS = 400; /** * Scale (or, at `factor <= 0`, mute) the non-visual channels. Muting DROPS * `sound` / `haptic` from `activeChannels` rather than scaling to zero, because * haptic intensity is floored (a `0` still buzzes). The visual hold and the * accessible `announce` channel are never touched. */ function attenuateNonVisual(effective: EffectiveSignature, factor: number): EffectiveSignature { if (factor >= 1) return effective; if (factor <= 0) { return { ...effective, activeChannels: effective.activeChannels.filter((c) => c !== 'sound' && c !== 'haptic') }; } const next: EffectiveSignature = { ...effective }; if (next.sound) next.sound = { ...next.sound, gain: next.sound.gain * factor }; if (next.haptic) next.haptic = { ...next.haptic, intensity: next.haptic.intensity * factor }; return next; } /** Activation level implied by an intent (book ch. 31 §5 / ch. 32 §2). */ function activationOf(intent: EffectiveSignature['intent']): number { switch (intent) { case 'threat': return 4; case 'fulfill': return 3; case 'risk': return 2; case 'loss': case 'affirm': return 1; default: return 0; } } /** * Dominance rank of an occurrence (higher wins). One comparable number for the * book's criteria: an evaluable event (commit / signal) outranks a structural * one; within that, higher activation outranks lower. Recency is handled by the * caller using a strict `>` so an equal-ranked newcomer stays audible. */ function occurrenceRank(effective: EffectiveSignature): number { const evaluable = effective.family === 'commit' || effective.family === 'signal' ? 1 : 0; return evaluable * 10 + activationOf(effective.intent); } /** * What a SHARED engine cannot be configured with from here. `masterGain` is * deliberately absent: it IS applicable (a live knob on the engine) and is * applied instead of dropped — the defect audit S-05 / S-32 measured. */ const SHARED_ENGINE_IGNORED = 'engine construction options were ignored: the engine is shared (injected by the composition root), so configure it there. `masterGain` is the exception and WAS applied.'; export class EngineSemantic { private readonly channels = new Map(); private nextSignalId = 0; /** * The perceptual map in force. NOT readonly: `applySounds` / `applyMap` * rebuild it from the canonical `SEMA_MAP` so a theme can be retuned live and * reverted, the same shape every eidos axis already has (`applyX` → managed * block → `clearX`). Every `emit` reads it fresh, so a retune takes effect on * the next occurrence with no re-boot. */ // Assigned in the constructor through `rebuildMap()`, which TypeScript's // definite-assignment analysis cannot follow across a method call. private map!: SemaMap; /** The seed the current map was built from — kept so a retune can rebuild. */ private mapSeed: Record | undefined; /** An installed sound pack, layered over the authored catalogue. */ private packOverride: Partial> | undefined; /** The root's shared engine, for warming when the sound channel is not ours. */ private sharedSoundEngine: EngineSound | undefined; /** Only consulted on that same path — the built-in channel owns its own copy. */ private soundPreferences: SemaPreferences | undefined; private readonly cascade: readonly SemaCascadeRule[]; private readonly logger: Logger | undefined; /** * Active non-transient signals — projection handles kept alive past the * hold for `untilAction` / `untilFix` / `stateBound`. Cleared via * `clear(id)` or `clearTarget(target)`. Per book §6.1: persistence is * caller-managed; the engine only holds the cleanup handle. */ private readonly active = new Map(); private readonly timers: SemaTimerScheduler | undefined; private readonly frequencyMemory: boolean; private readonly dominance: boolean; /** Per-event-key repeat counts for frequency memory (C-2). */ private readonly freqCounts = new Map(); /** Occurrences still "active" for dominance (C-3), keyed by occurrence id. */ private readonly activeOccurrences = new Map(); /** * The perceptual SURFACE registry: target → a promise that settles when the * occurrence currently projecting on it is done. * * `data-event-*` is one slot per element, so two occurrences on a node * cannot both express. `regime: 'queue'` reads this to wait its turn * instead of displacing (`SemaRegime`). Keyed by element and cleared by the * holder itself, so it never outlives the emit that created it. */ private readonly surfaces = new Map>(); constructor(opts: EngineSemanticOptions = {}) { this.logger = opts.logger; this.timers = opts.timers; this.frequencyMemory = opts.frequencyMemory ?? true; this.dominance = opts.dominance ?? true; // Layer 4 — runtime path overrides. The seed is REMEMBERED, not just // consumed, so `applyMap` / `clearMap` can rebuild from the canonical // map instead of layering onto whatever was applied before. this.mapSeed = opts.overrides?.runtime; assertMapPaths(SEMA_MAP, this.mapSeed); // The app's own sound pack, if it brought one: same layering as a runtime // `applySoundPack`, just decided at boot. this.packOverride = opts.sounds; this.rebuildMap(); // Layers 5a/5b — 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 ?? [])]; // The map's own samples — a boot-time `sounds` pack — warm alongside the // component packs' declared URLs. At runtime `warmSamples` does it. for (const url of namedSampleUrls(this.map)) { preloadUrls.add(url); } // Channels. if (opts.visual !== false) { const visualChannel: Channel = isChannel(opts.visual) ? opts.visual : new VisualChannel({ ...(opts.visual ?? {}), dom: opts.dom, projector: opts.projector, timers: opts.timers }); this.register(visualChannel); } if (opts.sound !== undefined && opts.sound !== false) { let soundChannel: Channel; if (isChannel(opts.sound)) { soundChannel = opts.sound; } else { const soundOptions = opts.sound === true ? {} : opts.sound; // Three shapes, in precedence order — and they are branches, not a // spread, because the channel's options are a discriminated union: // the engine's construction options exist ONLY when the channel // builds its own (they used to be accepted and dropped in silence). if (soundOptions.engine !== undefined) { soundChannel = new SoundChannel({ engine: soundOptions.engine, preferences: soundOptions.preferences ?? opts.preferences, logger: opts.logger }); } else if (opts.soundEngine !== undefined) { // The shared engine is owned by whoever injected it, so most // construction options cannot apply here. `masterGain` CAN — // it is a live knob on the engine (`master.setGain`) and the // natural way to set the volume from the composition root: // `createActiveUix({ events: { sound: { masterGain: 0.5 } } })`. // It used to compile and do absolutely nothing, because the // engine filtered the key one level ABOVE the channel's own // anti-smuggling warning (audit S-05 / S-32). if (soundOptions.masterGain !== undefined) { opts.soundEngine.master.setGain(soundOptions.masterGain); } const inapplicable = ( ['audioContextFactory', 'fetcher', 'dom', 'timers'] as const ).filter((key) => soundOptions[key] !== undefined); if (inapplicable.length > 0) { opts.logger?.warn('sema.sound', SHARED_ENGINE_IGNORED, { context: { ignored: inapplicable } }); } soundChannel = new SoundChannel({ engine: opts.soundEngine, preferences: soundOptions.preferences ?? opts.preferences, logger: opts.logger }); } else { soundChannel = new SoundChannel({ ...soundOptions, dom: soundOptions.dom ?? (isSoundChannelDom(opts.dom) ? opts.dom : undefined), timers: soundOptions.timers ?? opts.timers, preferences: soundOptions.preferences ?? opts.preferences, logger: opts.logger }); } } this.register(soundChannel); // Pre-decode the samples declared by component packs so the first emit // doesn't pay the fetch + decode latency. // // Both are REMEMBERED rather than just used here, because `warmSamples` // needs the same dispatch when a pack is swapped at runtime: having it // at boot only meant an app with its own sound channel warmed once and // never again. `opts.preferences` and not the channel's — the built-in // channel owns its own copy and this field is only read on the branch // where the channel is somebody else's. this.sharedSoundEngine = opts.soundEngine; this.soundPreferences = opts.preferences; if (preloadUrls.size > 0) this.warmUrls([...preloadUrls]); } if (opts.announce !== undefined && opts.announce !== false) { let announceChannel: Channel; if (isChannel(opts.announce)) { announceChannel = opts.announce; } else { const announceOptions = opts.announce === true ? {} : opts.announce; announceChannel = new AnnounceChannel({ ...announceOptions, dom: announceOptions.dom ?? (isAnnounceChannelDom(opts.dom) ? opts.dom : undefined) }); } this.register(announceChannel); } if (opts.haptic !== undefined && opts.haptic !== false) { let hapticChannel: Channel; if (isChannel(opts.haptic)) { hapticChannel = opts.haptic; } else { const hapticOptions = opts.haptic === true ? {} : opts.haptic; hapticChannel = new HapticChannel({ ...hapticOptions, dom: hapticOptions.dom ?? (isHapticChannelDom(opts.dom) ? opts.dom : undefined), timers: hapticOptions.timers ?? opts.timers, preferences: hapticOptions.preferences ?? opts.preferences, // A GETTER, not a snapshot: `applyMap()` retunes the feel of // every kind and the channel sees it on the next emission. profiles: hapticOptions.profiles ?? (() => this.map.haptics) }); } this.register(hapticChannel); } } // ── Theming the perceptual map ─────────────────────────────────────────── // // The same shape every eidos axis has — `applyX(seed)` installs, `clearX()` // reverts to the authored foundation — because half a perceptual system that // retunes live while the other half only reads its seed at boot is not a // theming system, it is two mechanisms wearing one name. Until 2026-08-06 // `overrides.runtime` was consumed in the constructor and never again: a // product could silence sound and could override one occurrence, but could // not re-voice the system after boot, and could not undo what it had set. /** * Retune the perceptual map live, from the CANONICAL map — not from whatever * is currently applied, so calling it twice is idempotent rather than * cumulative, exactly like `applyTheme(seed)` on the visual axes. * * Paths address the map: `families.commit.base.sound.gain`, * `intents.fulfill.deltas.sound.gain`, `sounds.tick.gain`. Takes effect on * the next `emit` — no re-boot, no re-registration of channels. * * @throws SemaConfigError when a path does not exist in the map. A theming * API that swallows a typo is worse than no API: the symptom is «the sound * did not change», with nothing to point at (audit S-09). */ applyMap(seed: Record | undefined): void { assertMapPaths(SEMA_MAP, seed); this.mapSeed = seed; this.rebuildMap(); } /** * Retune ONLY the sound catalogue, by name: `{ tick: { gain: 0.1 } }`. * Sugar over {@link applyMap} for the axis a product most often wants — the * VOICE of the system — without having to spell `sounds.` on every path. * * The vocabulary stays closed: `SoundName` is `keyof SEMA_MAP.sounds`, so a * theme changes what a name sounds like and cannot invent one. */ applySounds(seed: Partial>>): void { const paths: Record = { ...(this.mapSeed ?? {}) }; for (const [name, slice] of Object.entries(seed)) { for (const [key, value] of Object.entries(slice ?? {})) { paths[`sounds.${name}.${key}`] = value; } } this.applyMap(paths); } /** * Install a whole SOUND PACK — replace entries outright rather than tweak * their values. * * `applySounds` retunes what an entry already is (a gain, a pitch); * `applySoundPack` swaps the entry for a different KIND of thing, which is * what a real pack does: where the default has a synthesised recipe, a pack * may put an `.mp3`. Names the pack omits keep whatever they had, so a * partial pack is legitimate — it borrows the rest of its voice from the * default. * * The vocabulary does not change: a pack fills names, it cannot invent * them. Adding a NAME is a separate act (declare it, then register it). */ applySoundPack(pack: Partial>): void { this.packOverride = { ...(this.packOverride ?? {}), ...pack }; this.rebuildMap(); } /** Revert every retune AND any installed pack, back to the authored map. */ clearMap(): void { this.mapSeed = undefined; this.packOverride = undefined; this.map = SEMA_MAP; } /** One place that composes the authored map + path seed + installed pack. */ private rebuildMap(): void { const base = applyMapOverrides(SEMA_MAP, this.mapSeed); this.map = this.packOverride ? { ...base, sounds: { ...base.sounds, ...this.packOverride } } : base; this.warmSamples(); } /** * Pre-decode every sample the current map can reach. * * The engine's cache is keyed by URL, so a file is fetched and decoded ONCE * and every later play is free — but only from the SECOND play on. Swapping * a pack at runtime turns synthetic entries into files, and without this * each name paid its own round trip the first time it sounded, which is the * one time latency is audible. Warming costs one parallel burst instead. * * A no-op at boot (no channel is registered yet); the constructor folds the * same URLs into its own preload for that case. */ private warmSamples(): void { const urls = namedSampleUrls(this.map); if (urls.length > 0) this.warmUrls(urls); } /** * Hand URLs to whatever can pre-decode them. * * The built-in channel is preferred because it OWNS the sound preference: it * downloads nothing while sound is off and defers the request instead of * discarding it. A replacement channel has no such method, so the shared * engine is preloaded directly — and there the preference has no owner, so * the check is written here. It is read at CALL time, like everywhere else * the level is read, so flipping sound on and swapping a pack works. */ private warmUrls(urls: string[]): void { const channel = this.channels.get('sound'); if (channel instanceof SoundChannel) { void channel.preloadSamples(urls); return; } if (this.sharedSoundEngine && (this.soundPreferences?.sound ?? 'full') !== 'off') { void this.sharedSoundEngine.preload(urls); } } 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 id = signal.id ?? `sig-${this.nextSignalId++}`; const enriched: SemanticSignal = { ...signal, id }; // Explicit silence (capa 3 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 id; } // `queue` — the surface is one slot, and this occurrence declared that it // waits rather than displacing. Only the IRREDUCIBLE pairs use it: two // events the doctrine requires on one node (a toggle's contact + commit; // the knob's drop + set). Where the collision came from a redirection the // answer was to stop redirecting (A-36), not to arbitrate. // // The WHOLE emit waits, sound included — not just the projection. The // cascade resolves by matching selectors against the `data-event-*` this // signal is about to write, so projecting late while resolving early // would read the OTHER occurrence's stamp and pick its sound. // // Audio unlock is safe: you only queue behind something that just fired // on the same gesture, and that one already primed the context. if (signal.regime === 'queue' && signal.target) { const live = this.surfaces.get(signal.target); if (live) await live; } // Register this occurrence as the surface's holder, so a later `queue` // waits for it. Resolved in the `finally` below — including the failure // path, so a throw can never leave a surface permanently busy. let releaseSurface: (() => void) | undefined; let heldPromise: Promise | undefined; if (signal.target) { heldPromise = new Promise((resolve) => { releaseSurface = resolve; }); this.surfaces.set(signal.target, heldPromise); } const preparations: ChannelPreparation[] = []; for (const channel of this.channels.values()) { const handle = channel.prepare?.(enriched); if (handle) preparations.push(handle); } const persistence = signal.persistence ?? 'transient'; const shouldAutoCleanup = persistence === 'transient'; let cleanedUp = false; try { let effective = resolveSignature(enriched, { map: this.map, cascade: this.cascade }); // Post-resolution perceptual modulation. Both touch ONLY the // non-visual channels (sound / haptic); the visual hold is never // altered, so eidos's CSS reactions are unaffected. // C-2 frequency memory — repeated same-key signals attenuate. // C-3 dominance — a signal dominated by a still-active one is muted. effective = this.applyFrequencyMemory(enriched, effective); effective = this.applyDominance(enriched, effective); const visualChannel = this.channels.get('visual'); const otherChannels: Channel[] = []; for (const channel of this.channels.values()) { if (channel.id !== 'visual') otherChannels.push(channel); } // Free the surface when the HOLD elapses, not when the emit ends. // The emit also waits for `awaitExpression` — every running animation // on the target, capped at 1500ms — and gating the queue on that was // measured on a real Toggle to delay the queued commit by **1.6s**, // because unrelated transitions kept the node busy. What the holder is // owed is its registration floor; what happens after must not block // the next occurrence. The displaced projection is safe either way: // the unstamp checks ownership (`data-event-id`), so the holder's late // cleanup is a no-op once the queued one has taken the slot. if (releaseSurface && visualChannel instanceof VisualChannel) { const holdMs = visualChannel.holdMsFor(enriched, effective); semaDelay(this.timers, holdMs, releaseSurface, { channel: 'visual', signal: enriched.id }); } for (const channel of otherChannels) { channel.handle(enriched, effective).catch((err) => { this.logger?.error('sema', `channel "${channel.id}" failed`, { error: err, context: { channel: channel.id, signal: enriched.id } }); }); } // 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 { if (shouldAutoCleanup) { for (const handle of [...preparations].reverse()) { handle.cleanup(); } cleanedUp = true; } // Free the surface for anyone queued behind it. Two guards: only the // CURRENT holder clears the entry (a later `replace` may already have // taken it), and this runs on the throw path too — a rejected emit // must not leave a surface busy forever. if (signal.target && this.surfaces.get(signal.target) === heldPromise) { this.surfaces.delete(signal.target); } releaseSurface?.(); } // Persistent signals: keep projection alive past the hold. Caller // (typically a soma provider) clears via `clear(id)` or // `clearTarget(target)` when the relevant condition is met. If the // caller never clears, the projection lingers until dispose() — by // design: persistence is caller-managed per book §6.1. if (!cleanedUp) { this.active.set(id, { preparations, target: signal.target }); } return id; } /** * Clear an active persistent signal by id. Returns `true` if the signal * was found and cleared, `false` if it didn't exist (already cleared, * was transient, or never emitted). * * Call this from the caller that originally emitted the signal when * the underlying condition is met: * - `untilAction` — user acknowledged the alert. * - `untilFix` — the validation error was corrected. * - `stateBound` — the state ended. */ clear(id: string): boolean { const entry = this.active.get(id); if (!entry) return false; this.active.delete(id); for (const handle of [...entry.preparations].reverse()) { handle.cleanup(); } return true; } /** * Clear all active persistent signals whose `target` matches the given * element. Returns the count of signals cleared. Useful when a single * gesture invalidates multiple persistent signals on the same surface * (e.g. a form with three field warnings: one form-valid clears all). */ clearTarget(target: HTMLElement): number { let count = 0; for (const [id, entry] of this.active) { if (entry.target === target) { this.active.delete(id); for (const handle of [...entry.preparations].reverse()) { handle.cleanup(); } count++; } } return count; } /** * Whether a persistent signal id is currently active. Read-only helper * for diagnostics and tests; production code should not branch on this. */ hasActive(id: string): boolean { return this.active.has(id); } /** * C-2 — frequency memory (BK-FREQ-MEMORY, book ch. 32 §11). Attenuates the * non-visual channels of a signal repeated within a short window so a burst * of the same event fades instead of hammering. `threat` is exempt. A pause * of `FREQ_WINDOW_MS` resets the key to full intensity. */ private applyFrequencyMemory( signal: SemanticSignal, effective: EffectiveSignature ): EffectiveSignature { if (!this.frequencyMemory) return effective; if (effective.intent === 'threat') return effective; // `handle` is EXEMPT: a continuous gesture sounds by repetition, so its // emissions are supposed to be many and even. The anti-fatigue rule // would read the ratchet as hammering and choke it to the floor within // the first drag — the dynamics of a gesture are its RHYTHM, not a // level to be walked down. if (effective.family === 'handle') return effective; const key = signal.name || `${effective.family ?? ''}:${effective.intent ?? ''}`; const entry = this.freqCounts.get(key) ?? { count: 0 }; entry.count += 1; entry.reset?.cancel(); entry.reset = semaDelay(this.timers, FREQ_WINDOW_MS, () => this.freqCounts.delete(key), { channel: 'frequency' }); this.freqCounts.set(key, entry); const over = entry.count - FREQ_THRESHOLD; if (over <= 0) return effective; const atten = Math.max(FREQ_FLOOR, 1 - over * FREQ_STEP); return attenuateNonVisual(effective, atten); } /** * C-3 — dominance arbiter (BK-DOMINANCE, book ch. 30 §7). Registers each * occurrence as active for a short overlap window and mutes the non-visual * channels of an INCOMING signal that a still-active occurrence out-ranks * (evaluable > structural; higher activation; recency breaks ties in favour * of the newcomer). `threat` is never muted. The visual hold survives so the * dominated event still reads structurally. */ private applyDominance( signal: SemanticSignal, effective: EffectiveSignature ): EffectiveSignature { if (!this.dominance) return effective; // `emit` always enriches the signal with an id before this runs. The // old `?? sig-N++` fallback here was dead — and, had it ever fired, it // would have minted an id DIFFERENT from the one the rest of the // pipeline uses, desyncing the occurrence bookkeeping (SEM-3). A // degenerate un-enriched call now skips dominance instead. const id = signal.id; if (id === undefined) return effective; const rank = occurrenceRank(effective); let dominated = false; if (effective.intent !== 'threat') { for (const active of this.activeOccurrences.values()) { if (active.rank > rank) { dominated = true; break; } } } this.activeOccurrences.get(id)?.expire.cancel(); const expire = semaDelay( this.timers, DOMINANCE_WINDOW_MS, () => this.activeOccurrences.delete(id), { channel: 'dominance' } ); this.activeOccurrences.set(id, { rank, expire }); return dominated ? attenuateNonVisual(effective, 0) : effective; } getChannel(id: string): Channel | undefined { return this.channels.get(id); } dispose(): void { // Clean up any lingering persistent signal projections before tearing // down channels, so DOM doesn't keep stale data-event-* attrs. for (const entry of this.active.values()) { for (const handle of [...entry.preparations].reverse()) { handle.cleanup(); } } this.active.clear(); // Cancel the frequency-memory and dominance decay timers. for (const entry of this.freqCounts.values()) entry.reset?.cancel(); this.freqCounts.clear(); for (const entry of this.activeOccurrences.values()) entry.expire.cancel(); this.activeOccurrences.clear(); 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' ); } function isSoundChannelDom(value: unknown): value is SoundChannelDom { return ( value !== null && typeof value === 'object' && typeof (value as SoundChannelDom).getDocument === 'function' && typeof (value as SoundChannelDom).listen === 'function' ); } function isAnnounceChannelDom(value: unknown): value is AnnounceChannelDom { return ( value !== null && typeof value === 'object' && typeof (value as AnnounceChannelDom).getDocument === 'function' ); } function isHapticChannelDom(value: unknown): value is HapticChannelDom { return ( value !== null && typeof value === 'object' && typeof (value as HapticChannelDom).prefersReducedMotion === 'object' && (value as HapticChannelDom).prefersReducedMotion !== null ); }