You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
967 lines
39 KiB
967 lines
39 KiB
/**
|
|
* 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<string, DeltaValue>;
|
|
|
|
/**
|
|
* 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<Record<SoundName, NamedSound>>;
|
|
|
|
/** 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<string, Channel>();
|
|
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<string, DeltaValue> | undefined;
|
|
/**
|
|
* The PRODUCT's voice, fixed at construction (`opts.sounds`) — the
|
|
* foundation themes revert TO. `clearMap` keeps it, the same way clearing
|
|
* a theme does not unload the app's fonts.
|
|
*/
|
|
private readonly productPack: Partial<Record<SoundName, NamedSound>> | undefined;
|
|
/** The THEME layer `applySoundPack` installs; `clearMap` sheds it. */
|
|
private themePack: Partial<Record<SoundName, NamedSound>> | 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<string, PersistentSignalEntry>();
|
|
|
|
/**
|
|
* True when construction was passed `announce: false` — the composition
|
|
* root's default wiring (ActiveUix registers the shared-sink announce
|
|
* channel post-construction, S-19(ii)) reads this to honour the explicit
|
|
* opt-out. Construction options are otherwise not observable.
|
|
*/
|
|
readonly announceOptedOut: boolean;
|
|
|
|
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<string, { count: number; reset?: SemaTimerHandle }>();
|
|
/** Occurrences still "active" for dominance (C-3), keyed by occurrence id. */
|
|
private readonly activeOccurrences = new Map<string, { rank: number; expire: SemaTimerHandle }>();
|
|
|
|
/**
|
|
* 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<HTMLElement, Promise<void>>();
|
|
|
|
constructor(opts: EngineSemanticOptions = {}) {
|
|
this.logger = opts.logger;
|
|
this.announceOptedOut = opts.announce === false;
|
|
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 — the product FOUNDATION,
|
|
// not a theme layer: `applySoundPack` stacks on top and `clearMap`
|
|
// reverts to this, never past it.
|
|
this.productPack = 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<string>();
|
|
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) {
|
|
let visualChannel: Channel;
|
|
if (isChannel(opts.visual)) {
|
|
visualChannel = opts.visual;
|
|
} else {
|
|
// `nested ?? root` — the per-channel bag OVERRIDES, the engine's own
|
|
// services are the default. The same shape `sound`, `announce` and
|
|
// `haptic` implement below. It used to assign the root values
|
|
// unconditionally, so a projector handed in through the bag the type
|
|
// publishes was replaced by `undefined` and the constructor threw
|
|
// (audit S-10).
|
|
const visualOptions = opts.visual ?? {};
|
|
visualChannel = new VisualChannel({
|
|
...visualOptions,
|
|
dom: visualOptions.dom ?? opts.dom,
|
|
projector: visualOptions.projector ?? opts.projector,
|
|
timers: visualOptions.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<string, DeltaValue> | 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<Record<SoundName, Record<string, DeltaValue>>>): void {
|
|
const paths: Record<string, DeltaValue> = { ...(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<Record<SoundName, NamedSound>>): void {
|
|
// The THEME layer: per-name over the product's boot pack (`opts.sounds`),
|
|
// which is the foundation and is not touched from here.
|
|
this.themePack = { ...(this.themePack ?? {}), ...pack };
|
|
this.rebuildMap();
|
|
}
|
|
|
|
/**
|
|
* Revert every retune AND the theme's pack, back to the PRODUCT foundation:
|
|
* the authored map plus the boot pack (`opts.sounds`). The boot pack is the
|
|
* product's voice — «a property of the product rather than something a
|
|
* theme switches at runtime» (its own docblock) — so clearing a theme must
|
|
* no more uninstall it than clearing a theme unloads the app's fonts.
|
|
* (Until 2026-08-12 this wiped it too, unrecoverably: applying ANY theme
|
|
* without a `sound` axis silently destroyed the product voice.)
|
|
*/
|
|
clearMap(): void {
|
|
this.mapSeed = undefined;
|
|
this.themePack = undefined;
|
|
this.rebuildMap();
|
|
}
|
|
|
|
/**
|
|
* One place that composes the authored map + path seed + the two pack
|
|
* layers — theme over product, per name.
|
|
*/
|
|
private rebuildMap(): void {
|
|
const base = applyMapOverrides(SEMA_MAP, this.mapSeed);
|
|
const packs = { ...(this.productPack ?? {}), ...(this.themePack ?? {}) };
|
|
this.map =
|
|
Object.keys(packs).length > 0 ? { ...base, sounds: { ...base.sounds, ...packs } } : 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<string> {
|
|
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<void> | undefined;
|
|
if (signal.target) {
|
|
heldPromise = new Promise<void>((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
|
|
);
|
|
}
|