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

1067 lines
46 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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