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.
svelte-kit-vice/docs/architecture/sema.md

762 lines
30 KiB

---
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<void>
```
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<typeof semaSelector<typeof dialogMorfo>>[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).

Powered by TurnKey Linux.