--- 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 ``` 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 (Web Audio earcons + sample playback) └── 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 type { Sema } from '../sema-map'; import { sound, soundSampleUrls } from '../sounds'; export const dialogSema: Sema = { name: 'dialog', preloadSamples: soundSampleUrls(['notification.ping']), cascade: [ { selector: '[data-dialog-content][data-event-intent="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 { selector: '#critical [data-dialog-content]', 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. ### 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. ### 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` — comes from the resolver via `SEMA_MAP.families[*].hold`. 3. The family fallback table (`SEMA_DURATIONS` + per-family label) — defense when an external family declares no `hold`. 4. The channel's global `defaultHold` (240ms by default). See `SEMA_MAP.families[*].hold` for the concrete per-family 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: [ { selector: '#delete-confirm-dialog [data-dialog-content][data-event-intent="threat"]', sound: { sampleUrl: '/sounds/scary.wav', gain: 0.25 }, haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] } }, { 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 in the selector via the `[data-event-*]` tokens: ```ts // Vary by the event's intent { selector: '[data-toast-root][data-event-intent="threat"]', haptic: { kind: 'error' } } // Vary by family { selector: '[data-toast-root][data-event-family="signal"]', sound: { gain: 0.4 } } // Vary by exact event name { selector: '[data-toast-root][data-event="close-after-fail"]', sound: { sampleUrl: '/fail.wav' } } // Vary by event prefix { selector: '[data-dialog-content][data-event^="close-"]', sound: { contour: 'descending' } } ``` ### SoundChannel Short earcons synthesized via the Web Audio API from `effective.sound` (pitch / centroid / roughness / attack / decay / duration / contour / gain). Details: - A single `AudioContext` with a master `GainNode` per engine. - **Prepare-time priming**, no constructor side effect. The channel creates + resumes the `AudioContext` in `prepare()` when the signal admits `sound`, synchronously inside the user gesture. - After creating the context, it registers a `click` / `touchstart` / `keydown` listener via the injected DOM surface (`ActiveDom.listen(ActiveDom.getDocument(), ...)`) to re-resume after passive suspends (tab switch, etc.). If the channel never prepares an audible signal, it installs no global listeners. - Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) → ADSR-lite envelope. If `roughness > 0.2`, a fast AM modulator. - `contour` (`flat` / `ascending` / `descending` / `arc` / `bell`) is applied via `osc.detune`. - If the signature carries a `sampleUrl`, it plays the sample (with an `AudioBuffer` cache) instead of synthesizing. - Any failure (no AudioContext, decode 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_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.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. ## 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"]`). ```ts SEMA_VERBS = { contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'], commit: [ 'select', 'toggle', 'save', 'submit', 'confirm', 'complete', 'fail', 'cancel', 'reset', 'discard', 'delete', 'restore', 'expire', 'acknowledge', // Contextual verbs from the book (ch. 29 + ch. 30 + appendix C): 'apply', 'partial', 'block', 'move', 'set', 'remove', 'reorder', 'upload' ], signal: ['announce', 'notify', 'warn', 'alert', 'inform', 'emphasize', 'remind'], handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'], emerge: ['present', 'dismiss', 'open', 'close', 'expand', 'collapse', 'reveal', 'hide'], shift: ['enter-mode', 'exit-mode', 'navigate', 'route', 'step', 'return', 'context'], sustain: [ 'start', 'progress', 'loading', 'waiting', 'syncing', 'processing', 'streaming', 'pending', 'retrying', 'upload', 'end' ], // The delegate family (book ch. 29): events where the initiative changes // hands. Doesn't require AI — workflows, macros, approvals. delegate: ['offer', 'plan', 'authorize', 'act', 'review', 'escalate', 'return'] }; ``` **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_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md); the ruling vocabulary is [`CANON.md`](../CANON.md).