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

853 lines
35 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-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.
docs(sema): add "Continuous components" doctrine section (STUMBLES S2) The emit contract explains discrete events (one gesture → one emit → one hold) but says nothing about direct-manipulation surfaces (Slider, Drawer, future knob/swipe) where the pointer moves at sample rate while the value updates continuously. Add a `## Continuous components (drag, swipe, hold)` section after the emit contract, grounded entirely in the real Slider/Drawer code (adversarially fact-checked, zero discrepancies): - One door: `runtime.trigger()` is the sole path (there is no `emitEvent`). Its Promise represents the whole occurrence incl. the visual hold — so `void trigger()` is fire-and-forget (correct for high-frequency emits) and `await trigger()` blocks until the hold ends (correct when a structural change must observe the resolved signal, e.g. `close`). - `coincident` vs `post` for a moving value: the move (handle-drag, drag-progress) is `coincident` (signal + mutation indivisible); the commit (commit-set) is `post` (handler settles, then the signal celebrates). Slider is the worked example. - Throttle continuous POINTER emits, not keyboard: pointer emits are coalesced to animation frames + floored to a component-tuned interval via ActiveDom's requestFrame (Slider DRAG_SIGNAL_MS, Drawer DRAG_PROGRESS_SIGNAL_MS); keyboard commit-set fires once per keydown (already human-paced). Tuning ms point to the code, never hard-coded here. - The per-emit `overrides` payload owns pitch/gain/contour; a continuous event's cascade rule sets only `channels` and MUST NOT override those primitives, or it would clobber the live payload every frame. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
## 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"]`).
```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-semantica-historica.md`](../decisions/guia-semantica-historica.md);
the ruling vocabulary is [`CANON.md`](../CANON.md).

Powered by TurnKey Linux.