--- title: Sema — the perceptual engine and channels type: reference audience: human + agent authority: E1 architecture — the semantic domain, the emit contract, the resolution cascade and the channels status: current source: migrated from src/uix/sema/README.md (2026-07-02, docs-book F7.2) --- # Sema `Sema` defines UIX's canonical semantic domain and orchestrates the emission of perceptual signals. ## What it is - canonical families (8): `contact`, `commit`, `signal`, `handle`, `emerge`, `shift`, `sustain`, `delegate` - canonical intents: `neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss` - a per-family intent policy (`SEMA_FAMILY_POLICY`) with **two axes**: `intentRequirement` (`'required'` | `'optional'` | `'forbidden'`) — the compile-time gate that shapes the discriminated union — and `intentGuidance` (`'expected'` | `'contextual'` | `'discouraged'`) — a doctrinal hint for lint/tooling - normalization between the structured shape and the canonical label - minimal domain validation - `EngineSemantic` as the channel registry + occurrence dispatch `Sema` does not decide which event happened. The provider decides. `EngineSemantic` receives the occurrence and dispatches it to the registered perceptual channels. Each channel materializes the signal in its modality (DOM, audio, vibration). ## What it no longer is `Sema` is no longer a monolithic multimodal runtime. `EngineSemantic` does not contain: - a global accessibility policy - a cross-channel perceptual map - decisions about which modal effect to apply Those live in each channel separately: - `EngineSemantic` keeps a channel registry and dispatches each signal - the DOM projector materializes the signal as `data-event*` in the DOM - `SoundChannel`, `HapticChannel` and future modal engines register as independent channels that receive the signal and decide how to materialize it on their plane ### Channel registry — open The channel set is **NOT closed**. The framework ships canonical signatures for `sound` and `haptic`. The `visual` channel exists as the projection/hold meta-channel but has no slice of its own in `EffectiveSignature`. Apps can add channels with a signature via declaration merging: ```ts // app bootstrap declare module '$uix/sema' { interface SemaChannelSignatures { a11y: A11ySignature; // narrator / live-region voice: VoiceSignature; // text-to-speech } } ``` Only the visual channel is mandatory (with the `visual: false` escape). Sound and haptic are opt-in. Any new channel registers its `Channel` and receives the dispatch. ## The `emit` contract ```ts semantic.emit(signal: SemanticSignal): Promise ``` The resolved value is the **occurrence id**. Transient signals can ignore it; a non-transient one (`untilFix` / `untilAction` / `stateBound`, see [persistence](#persistence)) must be held so the caller can end it with `clear(id)` or `clearTarget(el)` — there is no other handle on it. One signature. It covers the three scenarios when composed with `dom.apply`: ```ts // Structural change without a signal dom.apply(change); // Structural change with a signal await semantic.emit(signal); dom.apply(change); // Signal without structural change void semantic.emit(signal); ``` ### Promise semantics — sequential strict `emit(signal)` resolves when the `VisualChannel` has completed its whole materialization: 1. `VisualChannel.prepare()` projected `data-event*` onto the DOM 2. the attributes lived in the DOM for the configured `hold` 3. the attributes were already removed (cleanup complete) 4. the Promise resolves This is **sequential strict**: the caller applies the structural commit AFTER the perceptual signal has finished. There is no parallelism between the event and the state change. Non-visual channels (sound, haptic) are fire-and-forget: they start in parallel with the visual one but do not affect the Promise's timing. ### `emit`'s internal lifecycle ``` 1. The engine generates the occurrence's id/session 2. The engine runs `channel.prepare(...)` on the registered channels - `VisualChannel.prepare()` projects `data-event*` for the cascade 3. The engine resolves the cascade and dispatches the signal to ALL registered channels - non-visual channels (sound, haptic) → fire-and-forget (not awaited) - the visual channel → awaited 4. The engine resolves the Promise when the visual channel has finished (strict sequential semantics: cleanup BEFORE the resolve) ``` ### Channels as modules Sema is organized in symmetric perceptual channels: ``` src/uix/sema/ ├── engine.ts registry + channel prepare/dispatch + cascade composition ├── resolver.ts resolveSignature(signal, opts): EffectiveSignature ├── stamp.ts stampEventAttrs / unstampEventAttrs (data-event-*) ├── channels.ts channel ids, signatures and override types ├── sounds.ts nominal sound repository + dynamic recipes ├── sema-map.ts SEMA_MAP data + per-component Sema packs ├── components/ per-component perceptual packs (CSEM) │ ├── dialog.ts dialogSema — cascade rules + preloadSamples │ ├── toast.ts (future) │ └── … └── chans/ ├── types.ts Channel interface — handle(signal, effective) ├── visual.ts VisualChannel (data-event projection + hold) ├── sound.ts SoundChannel (gate + reduction policy; the Web Audio │ runtime lives in the `$sound` art, injected) └── haptic.ts HapticChannel (Vibration API + categorical kinds) ``` The engine does not mutate DOM attributes directly: it runs the generic `channel.prepare(...)` hook. In the visual channel that hook delegates the projection to a `SignalProjector`. When `ActiveUix` builds it, that projector writes through UIX's `ActiveDom`. The engine resolves each signal into an `EffectiveSignature` with `hold`, `sound`, `haptic` and future typed channels, and dispatches `(signal, effective)` to each channel. Each channel reads its slice (`effective.sound` for audio, `effective.haptic` for vibration, etc.) or ignores the signature if it doesn't use it. Only the visual channel blocks the caller with the perceptual hold; the rest are fire-and-forget. ### Resolver and sema-map — the resolution cascade On every `emit`, the engine: 1. Runs the channels' `prepare` hooks. The `VisualChannel` projects the semantic tokens `data-event`, `data-event-family`, `data-event-intent`, `data-event-phase`, `data-event-id` onto `signal.target`. These are the tokens the cascade and eidos's CSS read. 2. Calls `resolveSignature(signal, opts)`, which applies the cascade (canonical numbering **1 · 2 · 3 · 4 · 5a · 5b** — the same in `engine.ts`, `resolver.ts` and CLAUDE.md; each layer overrides the previous): ``` 1. FAMILY base — SEMA_MAP.families[signal.family].base sound / haptic + activeChannels + hold 2. INTENT deltas — SEMA_MAP.intents[signal.intent] when present; numbers ADD by default — they are modifiers 3. MORFO overrides — signal.overrides + signal.channels (numbers REPLACE by default — they are set values) 4. RUNTIME overrides — engineOpts.overrides.runtime (path-based globals; baked into the map in the constructor; numbers REPLACE) 5a. PACK cascade — engineOpts.components (per-component packs) 5b. APP cascade — engineOpts.overrides.cascade (appended after the packs; wins specificity ties by declaration order). CSS-like selectors against signal.target with the data-event-* already stamped; numbers REPLACE. ``` 3. Dispatches to each channel with the resolved signature. 4. Awaits the VisualChannel (which contributes the hold). 5. Runs the cleanup of the handles returned by `prepare`. **Overrides vs deltas convention — numbers:** - **Layer 2 (intent.deltas)**: `pitch: -200` means "subtract 200 from the base pitch". Compositional modifiers. - **Layers 3, 4, 5a/5b (overrides)**: `pitch: 720` means "set pitch to 720". Like CSS — `gain: 0.4` doesn't add, it assigns. - To add explicitly from an override layer: `{ op: 'add', value: 100 }`. - To multiply: `{ op: 'multiply', factor: 1.2 }`. - To replace non-numeric primitives: `{ op: 'replace', value: ... }`. If `signal.family` is missing or not in the map, it returns an empty `EffectiveSignature` — the channels no-op. ### Semantic tokens — `data-event-*` `VisualChannel.prepare()` projects the following attrs onto `signal.target` BEFORE the cascade resolves. Rules with selectors over these attrs match natively via `target.matches()` / `target.closest()`: | Attr | Value | Origin | | ------------------- | -------------------- | ------------------------------ | | `data-event` | `'close-after-fail'` | `signal.name` | | `data-event-family` | `'signal'` | `signal.family` | | `data-event-intent` | `'threat'` | `signal.intent` (when present) | | `data-event-phase` | `'active'` | while the hold lasts | | `data-event-id` | `'sig-42'` | occurrence id | Those tokens are the **cross-channel contact surface**: sema's cascade (`sound`, `haptic` and future channels) reads them with CSS selectors, the same way eidos's CSS reads them to tint borders / animate states during the hold. One perceptual surface, separate owners. ### Cascade rules — flat CSS-like shape ```ts interface SemaCascadeRule { selector: string // CSS selector — matches state attrs + event tokens priority?: number // CSS-specificity override (optional) channels?: readonly SemaChannelId[] // restricts / silences active channels sound?: … haptic?: … } ``` One rule = one selector + one block of deltas. No intermediate `overrides: { eventLabel: ... }` layer — the event's identity is read from the selector via the `[data-event*]` tokens. ### Cascade selectors — typed builder, no hand-written strings Cascade rules in `sema/components/*.ts` MUST build their `selector` via `semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. The helper closes the loop between morfo's part/event contract and the selectors the cascade evaluates. ```ts import { semaSelector } from '$uix/morfo'; import { dialogMorfo } from '$uix/morfo/components/dialog'; const onContent = (matchers?: Parameters>[2]) => semaSelector(dialogMorfo, 'content', matchers); cascade: [ // [data-dialog-content][data-event-family="commit"] { selector: onContent({ eventFamily: 'commit' }), haptic: { kind: 'tap' } }, // [data-dialog-content][data-event="close-after-fail"] { selector: onContent({ eventName: 'close-after-fail' }), sound: { sampleUrl: '/fail.wav' } }, // [data-dialog-content][data-event^="close-"][data-event-family="emerge"] { selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }), sound: { contour: 'descending', pitch: { op: 'add', value: -150 } } } ]; ``` Compile-time guarantees: - `partKebab` is checked against `morfo.parts[].kebab`. - `eventName` is checked against `morfo.events[].name`. - `eventFamily` / `eventIntent` are typed against the canonical sema unions. - `state` / `aria` / `pseudo` accept plain strings (the data-attr vocabulary is per-component and not yet typed-derived). Renaming a part or event in morfo breaks the cascade at type-check time, not silently in production. **Hand-written selector strings in cascade rules are a code smell** — review them as drift. ### Per-component packs — `sema/components/{name}.ts` Each component ships its perceptual-defaults pack in `src/uix/sema/components/{name}.ts`, symmetric to soma and eidos: ```ts import { semaSelector } from '$uix/morfo'; import { dialogMorfo } from '$uix/morfo/components/dialog'; import type { Sema } from '../sema-map'; import { sound, soundSampleUrls } from '../sounds'; export const dialogSema: Sema = { name: 'dialog', preloadSamples: soundSampleUrls(['notification.ping']), cascade: [ { // `[data-dialog-content][data-event-intent="threat"]`, built from // the morfo contract — a part rename breaks at compile time. selector: semaSelector(dialogMorfo, 'content', { eventIntent: 'threat' }), sound: sound('notification.ping'), haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] } } ] }; ``` The app imports the packs it uses (tree-shakable): ```ts import { dialogSema } from '$uix/sema/components/dialog'; defineEngineSemantic({ components: [dialogSema], overrides: { cascade: [ // App-level rules win over packs on specificity ties. Apps use the // SAME typed builder; instance scoping goes in `ancestor`. { selector: semaSelector(dialogMorfo, 'content', { ancestor: '#critical' }), sound: { sampleUrl: '/x.wav' } } ] } }); ``` `preloadSamples` is concatenated across all packs and passed to `SoundChannel.preloadSamples()` when the engine is created, when the app asks for it explicitly — the WAVs can be decoded before the first emit. The global unlock listener is not registered in the channel's constructor; it is installed only once there is an `AudioContext` to unlock. ### When a component deserves a pack — participation doctrine (2026-07-07) `Morfo.expression` (`SemaExpressionMode`, [`morfo/types.ts`](../../src/uix/morfo/types.ts), author decision 2026-05-26) declares HOW a sema-scoped morfo contributes its perceptual signature. THIS section is the canonical doctrine; it was canonized at the component-audit checkpoint (verdicts S3a/S11 — historical record in [`docs/audit/components/_veredictos.md`](../audit/components/_veredictos.md)): four legitimate stances, each with shipped exemplars — 1. **`'pack'`** — a cascade in `sema/components/{kebab}.ts` tunes the signature per event. Justified when the pattern ADDS meaning beyond family + intent deltas (date-field's composed tunings; menubar's in-place note: "subtle commit + tap haptic for high-frequency top-level triggers"). 2. **`'family-default'`** — family base + intent deltas suffice. Legitimate when (a) the gesture is generic contact (button), (b) the book itself counsels restraint (command, citing ch. 22 §8: celebrating at the click is *"celebrate before time"* — the outcome fires downstream), or (c) a composed child supplies the character (field-langs: the embedded ToggleGroup's pack puts the tap). The reason is written IN the morfo. 3. **`'delegated'`** — composite; expression lives in the children's packs. The composite declares ONLY the events its children don't own (date-picker: its own `commit-reset`; selection/commit sound through the embedded date-field/calendar). The anti-duplication rationale is radio-cards' morfo header: *"declaring them here would duplicate the contract"*. 4. **Declared debt** — `events: []` with an honest "yet" comment (the generic Picker) is better than silence but MUST carry a deadline or become a decision: an undated "yet" is a hole in disguise. Coherence is guarded: `morfo-vocabulary-check` fails when a pack file exists and `expression` declares anything other than `'pack'`, and warns when a pack exists with no `expression` at all (verdict S11d). ### The sound repository — `sounds.ts` Components must not declare full sound signatures in every pack. Sema has a nominal repository: ```ts import { sample, sound, soundTuning } from '$uix/sema'; sound('handle.pickup.air'); soundTuning('emerge.exit.deep'); ``` An entry can be synthetic or point to an external `.wav` file with a synthetic fallback: ```ts sample('/sounds/uix/dialog-fail.wav', sound('handle.release.soft'), { preload: true }); ``` `SoundChannel` understands `sampleUrl`: it tries to play the WAV and, if fetch/decode fails, falls back to the synthetic signature. The rule is that components reference names; URLs and parameters live in one place. #### Tuning naming shapes A `SOUND_TUNINGS` key takes one canonical shape: ``` {family}.{tail} ``` - **`{family}`** — the sema family whose base signature the tuning modifies. Closed set (`SEMA_FAMILIES`), and the only axis that is both true and mechanically checkable: a component name is neither. Never a component, never a UI category. - **`{tail}`** — free prose describing the resulting SIGNATURE, one or more lowercase segments (`soft`, `subtle`, `medium`, `exit.deep`, `select.soft`). Deliberately NOT the triggering event: `commit.select.soft` dresses a menu item and a tree node alike, because it names the signature, not the gesture. This is the one place where the tuning vocabulary and the *event* vocabulary ([Naming shapes](#naming-shapes)) deliberately diverge. #### Silence is a value, not a tuning `SILENT` is THE canonical silence — one value for the whole system, exported from `$uix/sema`. Set a channel slice to it and **the channel that owns the slice ignores it and dispatches nothing downstream**: no signature is resolved, no engine is called, no context is opened. It admits no family, no verb and no intent, because there is nothing to modulate. That is why it is not a key: a per-family silencer would need to know which base it is cancelling, which is how the catalogue ended up with three of them. ```ts // a pack rule that does not speak in sound { selector: onProvider({ eventName: 'emerge-open' }), sound: SILENT } ``` **Absorbing by construction.** Intent deltas do not apply on top of `SILENT` (layer 2 cannot un-silence). A later override CAN lift it by REPLACING the slice with a full signature — declaring sound is a decision, not a delta. **Why it is a value and not a zero.** Until 2026-08-06 silence was arithmetic: `{family}.silent` subtracted the family's base gain so neutral landed at 0. A zero gain is a NUMBER, and later layers move numbers. Measured on a "silenced" toggle: an intent delta that raises `roughness` past the AM threshold (`risk` +0.2, `threat` +0.4) turned the signature into a raw tremolo at **−13.9 dBFS** (`risk`) and **−6.7 dBFS** (`threat`) — louder than a real button press at −9.3. The engine had already written the rule it was breaking: *«muting DROPS the channel rather than scaling to zero, because a `0` still buzzes»* (`engine.ts`). The grammar and the ban on silence-as-arithmetic are enforced by `src/uix/sema/sounds-grammar.test.ts`, which reads the families from `event.ts` and the base gains from `sema-map.ts` rather than restating them; the runtime half (`SILENT` reaches no engine) is pinned in `chans/sound.test.ts`. The guard exists because `form.*` named seven form controls truthfully on 2026-05-19, was extended to menus and trees a week later, and reached 50 packs — one of which was the Form — before anyone noticed, since no check ever looked at these keys. ### Channels and signatures | Channel | Consumed slice | Behavior when not applicable | | -------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | | `visual` | `effective.hold` for the hold | Falls back to the family table (SEMA_DURATIONS) or `defaultHold` | | `sound` | `effective.sound` (skips if `'sound'` not in activeChannels) | no-op | | `haptic` | `effective.haptic` (Vibration API, honors `prefers-reduced-motion`) | no-op if there is no `navigator.vibrate` | | (custom) | declaration merging of `SemaChannelSignatures` | read by the registered channel | ### The visual channel's attr namespace `VisualChannel.prepare()` projects **only** attributes under the `data-event-*` prefix: | Attr | When | | ------------------- | ------------------- | | `data-event` | always | | `data-event-id` | always | | `data-event-phase` | always (`'active'`) | | `data-event-family` | if `signal.family` | | `data-event-intent` | if `signal.intent` | **Rule**: the channel never touches state attrs (`data-state`, `data-intent`, `data-disabled`, ...). State is managed by the runtime/morfo. Reason: state is persistent and a signal is transient; reusing the same name would force the channel to erase state on cleanup (or into fragile save/restore if state mutates during the hold). For CSS: - `[data-event-intent='risk']` → reacts to the transient occurrence's intent - `[data-intent='risk']` → reacts to the component's persistent state Both can coexist on the same element with distinct semantics. ### Hold — the visual channel's resolution chain The `VisualChannel` resolves its `hold` (how long the `data-event-*` live in the DOM) in this order: 1. `signal.hold` — imperative per-call override. 2. `effective.hold` — composed by the resolver from `resolveHoldsByIntent(family, intent)` (`holds.ts`, `SEMA_HOLDS_BY_INTENT` — the ONE canonical hold source; the per-family `SEMA_MAP.families[*].hold` field was REMOVED 2026-07-06 because it duplicated this table and had drifted from the book's regions). 3. The same canonical table, consulted directly by the channel — defense when a custom map's resolver path didn't compose a hold. 4. The channel's global `defaultHold` (`brief`, 240ms by default). See `holds.ts` (`SEMA_HOLDS_BY_INTENT`) for the concrete family+intent values. **The hold timer runs on the managed scheduler, not on raw `setTimeout`.** The engine receives `timers` (in `ActiveUix` it is `uix.timers`) and forwards it to the three channels: the `VisualChannel`'s hold, the `HapticChannel`'s `delay` and the `SoundChannel`'s earcon duration are scheduled via `uix.timers.schedule(...)` (the `semaDelay` helper in `src/uix/sema/timers.ts`). That makes perceptual timing cancelable on `dispose`, observable, and deterministic under a fake clock in tests. It only falls back to `setTimeout` when a channel is built without a scheduler (direct unit tests); production always injects the scheduler. > **Overlay openings — `sequence: 'post'`, not `'pre'`.** An appearance event > whose provider sets `open` in the HANDLER (Popover `present`, Dialog > `open`, Drawer `present`) MUST declare `sequence: 'post'`. With `'pre'` the > runtime awaits the emit — and therefore the ~240ms hold — BEFORE the > handler, gating the content mount behind the hold: the overlay opens late > and its first render lands inside the hold's `setTimeout` turn (the > "setTimeout handler took N ms" violation). Same doctrine as the > checkbox-lag fix. Closing (`close`) stays `'pre'`: there the element exists > and the signal MUST precede the unmount. ```ts // Per-signal override semantic.emit({ ..., hold: 1200 }) // Override the visual channel's global default const semantic = new EngineSemantic({ dom, visual: { defaultHold: 400 } }) // Disable visual (DOM-less environments) const semantic = new EngineSemantic({ visual: false }) // Enable the built-in sound channel (opt-in: audible side effect) const semantic = new EngineSemantic({ dom, sound: true }) // With SoundChannel options const semantic = new EngineSemantic({ dom, sound: { masterGain: 0.6 } }) // Enable the built-in haptic channel (opt-in: device feedback) const semantic = new EngineSemantic({ dom, haptic: true }) // With HapticChannel options const semantic = new EngineSemantic({ dom, haptic: { masterIntensity: 0.7 } }) // Register custom channels (a11y, voice, future) class A11yChannel implements Channel { readonly id = 'a11y' async handle(signal, effective) { /* live-region updates, etc. */ } } semantic.register(new A11yChannel()) ``` ### Override layers — recipes ```ts import { dialogSema } from '$uix/sema/components/dialog'; const semantic = new EngineSemantic({ dom, sound: true, haptic: true, // Layer 5a — component packs (defaults shipped with each component) components: [dialogSema /* , toastSema, drawerSema, … */], overrides: { // Layer 4 — targeted SEMA_MAP edits, valid app-wide runtime: { 'families.commit.base.sound.pitch': 850, 'intents.threat.deltas.haptic.intensity': 0.4 }, // Layer 5b — app CSS-like rules, matched against signal.target // AFTER the component packs cascade: [ { // Typed builder here too — `ancestor` carries the instance id // (it lives outside the morfo's contract: plain string by design). selector: semaSelector(dialogMorfo, 'content', { eventIntent: 'threat', ancestor: '#delete-confirm-dialog' }), sound: { sampleUrl: '/sounds/scary.wav', gain: 0.25 }, haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] } }, { // Targets NO morfo-emitted attr (a global reduce gate over the // engine's own `data-event-*` stamps) — legitimately hand-written; // the builder mandate covers morfo-targeting selectors only. selector: ':root[data-sound="reduce"] [data-event-phase="active"]', priority: 100, sound: { gain: 0.05 }, channels: ['sound'] } ] } }); ``` ```ts // Layer 3 — per-event override declared in the component's own morfo. // Propagates via SomaRuntime → SemanticSignal → resolver. The app can // still override from the cascade (layers 5a/5b). // src/uix/morfo/components/dialog.ts { name: 'close-after-fail', semantic: { family: 'signal', verb: 'alert', target: v.partRef('content'), sequence: 'pre', intent: 'threat', // Per-event silencing: no sound, to avoid competing with the live region channels: ['haptic'], // Per-event override: the Dialog's own sample overrides: { haptic: { kind: 'error', pattern: [60, 80, 60, 80, 60] } } } } ``` ### Specificity between rules Like CSS: - **Computed selector specificity** — IDs × 100, attributes × 10, classes × 10, pseudo-classes × 10, elements × 1. - **Rules apply in ascending specificity order** — the last applied wins each pointwise conflict. - **Tie** → declaration order. Component packs appear BEFORE `overrides.cascade`, so app rules win ties. - **`priority?: number`** — overrides the computed value for cases that must win without counting attributes (typically accessibility, `priority: 100+`). To vary by event, intent, family, name, etc. — everything goes through the typed builder's matchers, which stamp the `[data-event-*]` tokens. (The hand-written examples this section used to show targeted `[data-toast-root]` — a part that never existed in the toast morfo: exactly the silent drift `semaSelector` turns into a compile error.) ```ts // Vary by the event's intent { selector: semaSelector(toastMorfo, 'item', { eventIntent: 'threat' }), haptic: { kind: 'error' } } // Vary by family { selector: semaSelector(toastMorfo, 'item', { eventFamily: 'signal' }), sound: { gain: 0.4 } } // Vary by exact event name (typed against the morfo's declared events) { selector: semaSelector(toastMorfo, 'item', { eventName: 'dismiss' }), sound: { sampleUrl: '/dismiss.wav' } } // Vary by event prefix { selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-' }), sound: { contour: 'descending' } } ``` ### SoundChannel — doctrine here, machine in `$sound` > **The Web Audio machinery does NOT live in sema** (since 2026-07-30). The > context lifecycle, the synthesis graph, the unlock-on-gesture and the sample > path were extracted to the art [`$sound`](../../src/arts/sound/README.md) > (`EngineSound`) — they were ~63% of a 407-line "channel" and are machinery, > not perceptual doctrine. Same move `$motion` made out of eidos, for the same > reason and with the same result: the art owns the RUNTIME, the layer keeps its > DATA and doctrine. What the channel keeps — all of it doctrine: - the `prepare` gate (does this signal admit sound at all?), - the per-channel reduction policy (BK-REDUCTIONS): `off` silences (meaning migrates via `SEMA_MIGRATION.sound` → presence / live region), `reduce` attenuates, - **its voice** — `SEMA_SOUND_VOICE` in `sounds.ts`, the synthesis calibration sema's vocabulary was tuned against, registered on the engine at channel construction (redesign 2026-07-31, D-SR.3 in `PLAN-sound-redesign.md`; it was baked into the art as constants before). Earcons play on the engine's **`ui` bus** with that voice — so UI-sound policy (BK-REDUCTIONS, resolved here and handed over as `gainScale`) can never touch the content's volume; the `ui` bus itself is the art's graph-side lever, with no direct prefs wiring today, - and the division of labour: **the channel resolves the LEVEL, the engine applies the gain**. Everything else — `SOUND_LIBRARY`, `SOUND_TUNINGS`, the gesture resolvers, the cascade — is unchanged and still sema's. **How it reaches the engine.** `EngineSemantic` takes a `soundEngine` option and forwards it. Both boot modes fill it without the integrator doing anything: standalone `ActiveUix` creates the engine and passes it in (exposing it as `uix.sound`); in attach, `defineEngineSemantic()` declares `sound` as a service dependency and forwards whatever `defineUixServices()` registered. The engine arrives as a structural port, the `MotionDom` / `SceneDom` pattern — sema never reaches for it. (**Precision**: the *instance* is injected, but the module graph is not free of the art — `chans/sound.ts` imports `createEngineSound` as a value for the private-engine fallback, so an app that builds an `EngineSemantic` still bundles the synthesizer with sound off. See `PLAN-sound-engine.md` §16.) Without an injected engine the channel creates a private one and owns its lifecycle — *whoever creates, disposes*; a shared engine is never closed by a consumer. That last rule is what lets a page rebuild its `EngineSemantic` freely: the sema studio does it on every draft edit, and the context survives. **Why one engine matters.** Browsers cap concurrent `AudioContext`s and the autoplay unlock gesture is per-context, so a second context leaves one of the two mute. An app that also plays content (a media player, a waveform) must take `uix.sound` — or pass `soundEngine` to its own `EngineSemantic` — instead of opening its own. The art warns when a second context goes live. Behaviour, unchanged by the extraction: - **Prepare-time priming**, no constructor side effect: the context is created + resumed in `prepare()` when the signal admits `sound`, synchronously inside the user gesture. - The unlock listener (`pointerdown` / `mousedown` / `click` / `touchstart` / `keydown`) is registered only AFTER the context exists, through the injected DOM surface. An engine that never plays installs no global listeners. - Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) → `roughness > 0.2` inserts a tremolo stage → ADSR-lite envelope; `contour` (`flat` / `ascending` / `descending` / `arc` / `bell`) rides `osc.detune`. The tremolo is a gain node IN SERIES at `1 - depth`, modulated on its own `.gain`, so the factor peaks at 1 and the trill scales WITH the envelope. It used to be patched onto `envelope.gain`, where a connection ADDS to the automation instead of scaling it: the depth stayed constant while the envelope moved, closing every `signal` note on a non-zero sample (an audible tick) and inflating a `commit.subtle` charged with `threat` to seven times its declared level. Pinned by `engine-sound.test.ts`. - A signature carrying `sampleUrl` plays the sample (with an `AudioBuffer` cache) and **falls back to synthesis** on fetch / decode failure. - Any failure is absorbed — sema is ornamental. The prepare-time priming pattern applies in general to any channel whose backend has a "first time must happen inside a gesture" restriction: audio, vibration, fullscreen, clipboard write. Documented as **convention 12** in [`guia-semantica-historica.md`](../decisions/guia-semantica-historica.md) (historical seed). ### Error policy - Errors in NON-visual channels are logged but never propagate. Sema is ornamental: an audio-context or vibration-API failure must not abort the provider's operation. - If the visual channel throws, the `emit` Promise rejects. The caller decides. - In fire-and-forget (`void semantic.emit(...)`), a visual rejection propagates as an unhandled promise — a conscious policy. ## Perceptual arbitration, reductions and accessibility (C-series, 2026-07-04) Landed from the Sema↔book audit (`sema-findings.md`, a process registry since removed from the tree); anchors in [`book-map.md`](../book-map.md). All opt-outable, all covered by unit tests. ### Frequency memory — `engine.frequencyMemory` (default on) `BK-FREQ-MEMORY` (ch. 32 §11): _"la gramática debe tener memoria de frecuencia"_. Repeated occurrences of the **same** event identity (`signal.name`, else `family:intent`) attenuate their non-visual channels — `sound.gain` and `haptic.intensity` — past a small threshold, down to a floor, so the 50th autosave doesn't sound like the first. `threat` is exempt; the visual hold is never touched. A window of silence resets the key to full. Distinct from the pointer-rate throttle (that samples ONE continuous gesture; this damps _repeated_ discrete signals). Disable with `frequencyMemory: false`. ### Dominance arbiter — `engine.dominance` (default on) `BK-DOMINANCE` (ch. 30 §7): when signals overlap, the engine ranks each active occurrence (evaluable `commit`/`signal` > structural; higher activation from intent; recency breaks ties for the newcomer) and **mutes the non-visual channels** of an incoming signal a still-active occurrence out-ranks — "lo evaluable calla a lo estructural". `threat` is never muted; the visual hold and the `announce` channel survive (a dominated event still reads structurally and remains accessible). Occurrences stay active for a short overlap window. Disable with `dominance: false`. ### Announce channel — `engine.announce` (opt-in) `BK-SIGNAL-A11Y` / `BK-A11Y-CRITICAL`: the accessible live region is now a first-class **channel** (`AnnounceChannel`, id `announce`), opt-in like sound / haptic. It reads the human-facing text from `signal.message` (never inferred from the technical event name), derives priority from intent (`threat`/`loss` → `assertive`, else `polite`), and materializes it either through an injected announcer (`{ announce }`, e.g. `ActiveUix.announce`, one shared region) or a self-owned pair of live regions built from the injected `dom`. Fire-and-forget, never rejects. Supersedes `book-deviations.md` D.8's deferral. **Live-region doctrine — who owns what (AUX-1 / SEM-1, 2026-07-11).** The framework has ONE sink, two emitters, and an app-level component: - `uix.announce` (ActiveUix) owns the SHARED pair of live regions — the low-level sink (its zwsp-toggle re-announce is an implementation detail of that sink, not a third policy). - The two framework **emitters** both prefer delegating to it: the soma runtime's a11y commitments (`a11ySemantic.requiresLiveRegion` → `sources.announce`) and this channel; its self-owned clear-then-set regions are ONLY the no-uix fallback. ⚠️ **The two signatures do not meet, so the delegation needs an adapter.** `AnnounceFn` takes the priority in an options bag and `uix.announce` takes it positionally, so the literal `{ announce: uix.announce }` this section used to prescribe does not compile: ```ts announce: (message, { priority }) => uix.announce(message, priority); ``` And **no composition root wires it today** — neither `createActiveUix` nor `attachActiveUix` passes `announce` to the engine, so enabling the channel currently lands on the self-owned fallback, which adds a SECOND pair of `role=status` / `role=alert` regions next to the shared one. Fixing this is a code decision (adapter at the root, or reshape `AnnounceFn`) that has not been taken — audited 2026-08-05. - The soma `` component is different in kind: an app-authored, declarative region for app-level messages — not a vehicle for framework event announcements. Priority is ONE policy everywhere (SEM-1 — the runtime used to add a `family === 'signal'` conjunct, making a threat-tinted commit announce polite on one path and assertive on the other): the evaluative INTENT alone — `threat` / `loss` → `assertive`, else `polite` — implemented identically in `priorityForIntent` (`chans/announce.ts`) and the runtime's a11y step (`runtime.svelte.ts`), pinned by tests on both sides (`announce.test.ts` · `runtime.svelte.test.ts` SEM-1). ### Per-channel reductions — `engine.preferences` `BK-REDUCTIONS` (ch. 32 §12 / ch. 33 §7): a symmetric reduction story per channel. `SemaPreferences { sound?, haptic?: 'full' | 'reduce' | 'off' }` is read at dispatch (back it with reactive state to make it live). Sound gains a reduce/off path (attenuate master gain / no-op) symmetric to haptic; haptic _also_ keeps honoring `prefers-reduced-motion`. Visual-motion reduction stays in eidos CSS; when a channel is `off`, meaning migrates via the table below. ### Migration table — `sema/migration.ts` `BK-A11Y-MIGRATION` (ch. 33): _"cuando un canal falla, migra el significado, no el adorno"_ — as **data**, not just prose. `SEMA_MIGRATION` gives per-modality fallback surfaces (motion → state/focus/text; color → shape/icon/text; sound → presence/text/liveRegion; haptic → motion/shape/state); `SEMA_MIGRATION_BY_FAMILY` refines the cases the book calls out (signal-sound → live region; commit-color → footprint). `resolveFallback(modality, family?)` is the accessor a11y / eidos consult. ### Frame-intent guardrail (morfo) `BK-FRAME-NO-INTENT` (Apéndice A): a structural family (`contact` / `emerge` / `shift` / `sustain` / `delegate`) that declares a non-neutral intent must justify it with `MorfoEventSemantic.intentRationale`, or `validateMorfo` rejects it. This makes the "appearance that is the warning" exception ([book-updates A-1](../audit/book-updates.md)) explicit and auditable rather than a silent loophole. `handle` is exempt (the book puts intent on `handle.drop`); `commit`/`signal` require intent anyway. ## Continuous components (drag, swipe, hold) Most events are discrete: one gesture, one emit, one hold. Direct-manipulation components — Slider, Drawer, and any future knob / swipe surface — are **continuous**: the pointer moves at sample rate (dozens to hundreds of events per second) while the value updates the whole time. Emitting one perceptual signal per pointer sample would flood the channels and desynchronise the hold. The rules below keep a continuous gesture perceptually legible without breaking the emit contract. ### One door: `runtime.trigger(eventName, opts?)` There is no `emitEvent`. Providers reach the engine through a single method, `runtime.trigger(name)`, which resolves the morfo's compiled action, applies polymorphic overrides, runs the handler in the declared `sequence`, awaits the perceptual emit, and honours the event's a11y commitments. It returns a `Promise` that represents **the whole occurrence, including the visual hold** — the same sequential-strict window described in _Promise semantics_. That Promise is the control the caller uses to opt into or out of the hold: - `void runtime.trigger('handle-drag')` — **fire-and-forget**. The signal starts; the caller does not wait for the hold. Correct for high-frequency emits, where blocking the gesture loop on a ~240ms hold would be absurd. - `await runtime.trigger('close', …)` — **blocking**. The caller waits for the hold to finish before its next step. Correct when a structural change must observe the resolved signal (e.g. a `close` whose element unmounts after the pulse — see the `sequence: 'post'` note under _Hold_). Slider and Drawer go through `trigger` exclusively — `handle-pick`, `handle-drag`, `commit-set` on the slider; `drag-start`, `drag-progress`, `drag-end` on the drawer. Every continuous one is `void`. ### `coincident` vs `post` for a moving value A continuous gesture has two temporally distinct moments, and the morfo encodes each with its `sequence`: - **The move** (`handle-drag`, `drag-progress`) is `sequence: 'coincident'` — the perceptual signal and the value update are indivisible. The user _is_ the value changing, so the signal fires alongside the mutation, neither anticipating nor trailing it. (`coincident` is emit-then-handler, like `pre`; the two are just declared indivisible.) - **The commit** (`commit-set`) is `sequence: 'post'` — the handler settles the resolved value first, then the signal celebrates it. The move is felt continuously; the commit is felt once, after. (`post` is handler-then-emit; see the `sequence` comment in `runtime.svelte.ts` and the overlay note under _Hold_.) Slider is the worked example: its morfo declares `handle-pick` / `handle-drag` as `handle` · `coincident` and `commit-set` as `commit` · `post`. ### Throttle continuous pointer emits; leave keyboard alone `onpointermove` fires far faster than the eye or ear resolves. Continuous emits are therefore **coalesced to animation frames** and floored to a component-tuned minimum interval — both via `ActiveDom`'s `requestFrame` (the sanctioned rAF vehicle; never a raw `requestAnimationFrame` or `setTimeout`): - Slider's `queueHandleDrag` stores the pending value, schedules one `requestFrame`, and inside it skips the emit when less than `SliderProvider.DRAG_SIGNAL_MS` has elapsed since the last signal. - Drawer's `flushDragProgress` does the same with `DRAG_PROGRESS_SIGNAL_MS`, deliberately set _below_ rAF cadence (sparser than one signal per frame). The concrete millisecond floors are perceptual tuning, not architecture — they live at those constants in the providers, never copied here. Keyboard is the opposite case and is **not** throttled: Slider's `onkeydown` calls `commitValue()` → `trigger('commit-set')` on every arrow / Page / Home / End press. OS key-repeat is already human-paced and every repeat is a discrete committed value, so one emit per keydown is correct. Throttling is a property of _sub-perceptual pointer sampling_, not of continuous components in general. ### The per-emit payload owns the primitives; the cascade only adds character `handle-drag` carries a dynamic sound payload — pitch / gain / contour derived from pointer position and velocity — passed per-emit through `trigger`'s `overrides`. For that live payload to survive frame after frame, the component's sema pack for the continuous event sets **only** `channels: ['sound', 'haptic']` and overrides nothing else (`sema/components/slider.ts`). > A cascade rule for a continuous event MUST NOT set `pitch` / `gain` / > `contour` (nor per-intent haptic `intensity`). Those are exactly what the > per-emit `overrides` control; a rule that also set them would clobber the live > payload on every frame and flatten the gesture to a constant tone. The rule > enables the channels and adds character — it never fixes the evaluative > primitives. (Same division as _Override layers_ and the canon rule "cascade > rules add character, never the intent's evaluative profile".) ## The per-family intent policy — `SEMA_FAMILY_POLICY` > **Canonical:** [`CANON.md`](../CANON.md) §4 is the single source for this > policy. The shape below mirrors `SEMA_FAMILY_POLICY` in `types.ts`; if they > ever disagree, the code + canon win. The doctrine on when intent is mandatory lives in a const in `src/uix/sema/types.ts`. Each family declares **two independent axes**: ```ts export const SEMA_FAMILY_POLICY = { contact: { intentRequirement: 'optional', intentGuidance: 'discouraged' }, commit: { intentRequirement: 'required', intentGuidance: 'expected' }, signal: { intentRequirement: 'required', intentGuidance: 'expected' }, handle: { intentRequirement: 'optional', intentGuidance: 'contextual' }, emerge: { intentRequirement: 'optional', intentGuidance: 'contextual' }, shift: { intentRequirement: 'optional', intentGuidance: 'contextual' }, sustain: { intentRequirement: 'optional', intentGuidance: 'contextual' }, delegate: { intentRequirement: 'optional', intentGuidance: 'contextual' } } as const; ``` - **`intentRequirement`** (`'required' | 'optional' | 'forbidden'`) — the compile-time gate that shapes the discriminated union. `'required'` (only `commit` and `signal`) → `intent` is MANDATORY in `MorfoEventSemantic`. `'forbidden'` is reserved; no family uses it today. - **`intentGuidance`** (`'expected' | 'contextual' | 'discouraged'`) — a doctrinal hint with no type effect. `commit`/`signal` are `'expected'`; `contact` is `'discouraged'` (book ch. 22 §11: "strong intent should not live in the contact"); the rest are `'contextual'`. **How it is enforced:** 1. **Compile time** — `SemaEvent` and `MorfoEventSemantic` are discriminated unions derived from the `intentRequirement` axis. Flipping a family from `'optional'` to `'required'` forces every morfo of that family to declare an intent or fail the typecheck. 2. **Runtime** — `validateSemaEvent` throws when an event of a family with `intentRequirement: 'required'` is built without intent (a defense against malformed morfos or external inputs). **Why this policy replaced the valenced/transitional split:** The original canonical doctrine assumed only valenced families (contact, commit, signal, handle) could declare intent. Transitional ones (emerge, shift, sustain) were intent-less by definition. UX reality contradicted it: a Dialog opening to confirm a destructive delete carries threat in its very appearance. The policy distinguishes by the practical NEED for intent, not by taxonomic category. The valenced/transitional distinction still exists as a classification, but it no longer dictates the intent rules — the policy does. ## The canonical verb vocabulary (`SEMA_VERBS`) > **Canonical:** [`CANON.md`](../CANON.md) §6 + > [`verbs.ts`](../../src/uix/sema/verbs.ts) are the source of truth for the > verb vocabulary. This section documents how sema _consumes_ it (validation, > naming shapes). Cross-component action verbs grouped by family. The canon lives in [`verbs.ts`](../../src/uix/sema/verbs.ts) and reflects the book _Diseñando lo que ocurre_ ch. 22–29 (families) + ch. 10 (intents). `morfo.events[].semantic.verb` MUST be in this vocabulary; `morfo.events[].name` should follow the `{family}-{verb}[-{variant}]` shape so sema/sound/haptic can subscribe by verb and eidos can write transversal selectors (`[data-event^="dismiss"]`). > **The literal table used to live here, and it rotted.** It was missing > `commit.unselect` and `handle.zoom` — both live in `verbs.ts` and both used > by real morfos. A copy of a closed set is a copy that goes stale; the > generated enumeration in > [`canon/vocabularies.md`](../canon/vocabularies.md#sema-verbs-by-family) > is regenerated from the const by `npm run docs:vocabularies` and guarded for > freshness by `npm run docs:check`. Removed 2026-08-06. **Verbs that look like one family but belong to another** per the canon: `select`/`toggle`/`acknowledge` are `commit` (they fix state); `edit` is expressed as `shift.enter-mode` (it changes the regime — there is no `edit` verb in handle). Defined in [`verbs.ts:SEMA_VERBS`](../../src/uix/sema/verbs.ts). ### Naming shapes A `morfo.events[].name` can take two canonical shapes: ```ts // Shape 1: {verb}-{variant} — head is the verb, tail explains the nuance. 'dismiss'; // bare verb 'dismiss-outside'; // verb + variant 'close-cancel'; // verb (close) + variant (cancel) // Shape 2: {family}-{verb} — head is the family, tail the canonical verb. 'commit-toggle'; // family=commit, verb=toggle 'commit-save'; // family=commit, verb=save ``` `validateEventName(name)` recognizes both shapes and returns `{ family, verb, variant, matchesCanonical }`. Used by `scripts/morfo-vocabulary-check.ts` (`npm run morfo:vocabulary`), which hard-fails when a morfo's declared `semantic.verb` is not in its family's canon, and soft-warns when the event NAME doesn't fit `{family}-{verb}[-{variant}]` even though the verb is canonical. A temporary allowlist in the script covers deliberate divergences. ```ts import { validateEventName } from '$uix/sema'; validateEventName('commit-toggle'); // { name: 'commit-toggle', head: 'commit', matchesCanonical: true, // variant: 'toggle', family: 'commit', verb: 'toggle' } validateEventName('dismiss-outside'); // { name: 'dismiss-outside', head: 'dismiss', matchesCanonical: true, // variant: 'outside', family: 'emerge', verb: 'dismiss' } validateEventName('frob-glob'); // { name: 'frob-glob', head: 'frob', matchesCanonical: false, // variant: 'glob', family: undefined, verb: undefined } ``` ## Relationship with Morfo and Soma - `Morfo` declares the component's semantic events in `morfo.events` - `Provider` decides when they occur and calls `semantic.emit(...)` - `SomaRuntime` orchestrates the `prewrite -> emit -> handler -> effects` sequence - `Sema` contributes the vocabulary, normalization and domain validation, and publishes the occurrences ## Dependencies - `EngineSemantic` writes no attributes directly. It orchestrates channel hooks (`prepare`, `handle`, `cleanup`) without knowing the DOM attrs. - Each channel manages its own modality: - `VisualChannel.prepare()` projects `data-event-*` and then holds the perceptual window. - `DomSignalProjector` is the DOM writer used by the visual channel; it writes through the `ActiveDom` received from `ActiveUix`. - Other channels (sound, haptic) access their respective APIs (`AudioContext`, `navigator.vibrate`, etc.). - In normal use, `ActiveUix` injects the `ActiveDom` into `EngineSemantic`. Using Sema directly outside `ActiveUix` must pass an explicit `dom/projector` or choose a documented degradation. ## Architecture rule `Morfo` authorizes the component's semantics. `Sema` defines the canonical vocabulary and dispatches signals to channels. `Provider` decides when to emit. Each `Channel` materializes the signal in its modality. See [`architecture/overview.md`](./overview.md) §2.bis for the cross-layer view. The original Spanish API conventions (single-event for instantaneous operations, intent ↔ visual token resolution, sound prepare-time priming) survive as a historical seed in [`guia-semantica-historica.md`](../decisions/guia-semantica-historica.md); the ruling vocabulary is [`CANON.md`](../CANON.md).