60 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| Sema — the perceptual engine and channels | reference | human + agent | E1 architecture — the semantic domain, the emit contract, the resolution cascade and the channels | current | 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 — andintentGuidance('expected'|'contextual'|'discouraged') — a doctrinal hint for lint/tooling - normalization between the structured shape and the canonical label
- minimal domain validation
EngineSemanticas 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:
EngineSemantickeeps a channel registry and dispatches each signal- the DOM projector materializes the signal as
data-event*in the DOM SoundChannel,HapticChanneland 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:
// 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
semantic.emit(signal: SemanticSignal): Promise<string>
The resolved value is the occurrence id. Transient signals can ignore it;
a non-transient one (untilFix / untilAction / stateBound, see
persistence) must be held so the caller can end it with
clear(id) or clearTarget(el) — there is no other handle on it.
One signature. It covers the three scenarios when composed with dom.apply:
// 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:
VisualChannel.prepare()projecteddata-event*onto the DOM- the attributes lived in the DOM for the configured
hold - the attributes were already removed (cleanup complete)
- 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:
- Runs the channels'
preparehooks. TheVisualChannelprojects the semantic tokensdata-event,data-event-family,data-event-intent,data-event-direction,data-event-phase,data-event-idontosignal.target. These are the tokens the cascade and eidos's CSS read. - Calls
resolveSignature(signal, opts), which applies the cascade (canonical numbering 1 · 2 · 3 · 4 · 5a · 5b — the same inengine.ts,resolver.tsand CLAUDE.md; each layer overrides the previous):
THE SOUND — two lookups, and nothing modulates:
nombre = per-emit ?? cascada ?? pack ?? morfo ?? familia[verbo] ?? familia.default
sonido = pack[`${nombre}.${intent}`] ?? pack[nombre] ?? nada
EVERYTHING ELSE — a CSS-like cascade, each layer over the previous:
1. FAMILY base — SEMA_MAP.families[f].base (haptic + activeChannels + hold)
2. INTENT deltas — SEMA_MAP.intents[i].deltas; HAPTIC ONLY since 2026-08-06
3. MORFO overrides — signal.overrides + signal.channels (minus `sound`)
4. RUNTIME overrides — engineOpts.overrides.runtime, baked into the map
5a. PACK cascade — engineOpts.components (minus their `sound`)
5b. APP cascade — engineOpts.overrides.cascade
The sound does not participate in that cascade, and that is the design. An intent SELECTS a whole sound; it never bends one. A pack NAMES a sound; it never authors one. There is nothing to override because there are no parameters to override — a rule carries a name, and a name replaces a name.
What that removed, and why it is worth knowing before you go looking for the machinery that used to be here: sound used to be resolved through all six layers, with intent deltas adding to a family base signature and pack rules replacing primitives afterwards. Measured across the 71 packs, that produced 214 rules authoring 33 distinct signatures, of which five were the same 700 Hz note at five volumes and the «direction» variants differed by 20 Hz. The theory was expressive; the output was mush. It also generated a whole class of defect (D.7, the S-07 finding, three guards) whose only job was to police arithmetic that should never have been in a component.
Precedence among the things that NAME. Most specific wins: a name passed at trigger-time beats the app cascade, which beats a pack rule, which beats the morfo event, which beats the family's verb table. This INVERTED the old order, in which the cascade beat the per-emit override — whoever fires the occurrence knows it best.
The verb tier is what empties the packs. SEMA_MAP.families[f].sounds is
keyed by the event's canonical VERB, with default as the fallback cell, so
emerge + close finds close without one line in any overlay's pack. The
verb travels in the signal for exactly this reason (it used to be computed and
discarded — audit S-40). A pack only speaks when it DIFFERS from the default:
the framework's 71 packs carry 30 sound rules between them.
- Dispatches to each channel with the resolved signature.
- Awaits the VisualChannel (which contributes the hold).
- Runs the cleanup of the handles returned by
prepare.
Overrides vs deltas convention — numbers. Applies to the HAPTIC channel
and to hold; the sound channel has no numbers to converge on any more.
- Layer 2 (intent.deltas):
intensity: 0.2adds to the base. Modifiers. - Layers 3, 4, 5a/5b (overrides):
intensity: 0.7sets. Like CSS. - To add explicitly from an override layer:
{ op: 'add', value: 0.1 }. - 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 |
'signal-alert-close-fail' |
signal.name |
data-event-family |
'signal' |
signal.family |
data-event-intent |
'threat' |
signal.intent (when present) |
data-event-direction |
'forward' |
signal.direction (when present) |
data-event-phase |
'active' |
while the hold lasts |
data-event-id |
'sig-42' |
occurrence id |
data-event-direction (forward / backward, SemaDirection) is the SENSE of
a traversal, decided per emit: the event name already separates
shift-enter-mode from shift-exit-mode, but one shift-navigate goes to the
previous month and the next one to the following month under the same name. It
is a sense, never an axis — eidos maps it onto the inline axis so :dir(rtl)
flips it. A route with no clear sense (a month picked from a select) stamps
nothing.
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.
The surface is ONE SLOT — ownership and regime
Those six attrs are a single slot per element. Two occurrences on one node cannot both express, and the framework spent a year not saying so: three independent audits found the same defect and none closed it (fable S1 2026-07-01 · sema S-17 2026-08-05 · blocks A-36 / A-65, reproduced in the browser). Two rules now hold it shut.
1 · The unstamp checks ownership. unstampEventAttrs retires the
projection only when data-event-id still matches the signal that wrote it.
Before 2026-08-10 it took the signal and discarded it, so the occurrence
finishing FIRST wiped whichever one currently held the slot — measured on the
knob, a commit-set projection died 24 ms into a 240 ms hold.
2 · regime says what an ARRIVING occurrence does to a busy surface
(SemaRegime, declared per morfo event, default replace):
| Value | Behaviour |
|---|---|
replace |
Takes the surface at once, displacing the live projection. Default — and correct only since rule 1. |
queue |
Waits for the live occurrence's HOLD to elapse, then takes it. |
queue waits for the hold, never for the whole expression.
awaitExpression keeps the projection alive until the target's animations
finish (capped at MAX_EXPRESSION_WAIT_MS), which is right for unstamping and
wrong for queueing: gating on it delayed a Toggle's queued commit-toggle by
1.6 s, because unrelated transitions kept the node busy. The engine and the
channel resolve that number through one path (VisualChannel.holdMsFor).
Reach for regime last. It is for pairs the doctrine REQUIRES on one node
and no re-targeting can separate — a toggle's contact-press + commit-toggle
(its provider IS the button), the knob's handle-drop + commit-set. When the
collision comes from a redirection instead, the fix is to stop redirecting:
that is how A-36 closed, with the overlays' emerge-open giving up a
targetOverride left over from when they were sequence: 'pre'. Measured
after: the Button's contact-activate stamps the trigger and the Drawer's
emerge-open stamps its content — two surfaces, both expressing.
The redirection those overlays DID need — landing emerge-close on the trigger
once the content has unmounted — is not a targetOverride either: it is
mount-state resolution, declared in the morfo as targetFallback and
resolved by the runtime through the same single path that moves a11y focus
(resolveEmitTarget). A rule may select a fallback part — the census counts it
stampable — but it only fires in the degraded mount; the routine surface is
still the declared target. The field, its invariants and its split from
allowedTargets: architecture/morfo.md §Step 5.5.
collapse and lock were declared here from the founding commit and never
meant anything; both were retired on 2026-08-10 rather than left as a contract
that lies. The reasons are in SemaRegime's own docblock.
Cascade rules — flat CSS-like shape
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.
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="signal-alert-close-fail"]
{ selector: onContent({ eventName: 'signal-alert-close-fail' }), sound: { sampleUrl: '/fail.wav' } },
// [data-dialog-content][data-event^="emerge-close"][data-event-family="emerge"]
{
selector: onContent({ eventNamePrefix: 'emerge-close', eventFamily: 'emerge' }),
sound: { contour: 'descending', pitch: { op: 'add', value: -150 } }
}
];
Compile-time guarantees:
partKebabis checked againstmorfo.parts[].kebab.eventNameis checked againstmorfo.events[].name.eventFamily/eventIntentare typed against the canonical sema unions.state/aria/pseudoaccept 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:
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):
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, together with every
sample URL the map itself can reach — so installing a pack at runtime
(applySoundPack) warms it too, instead of leaving the first play of each name
to pay its own round trip. Preloading does NOT wait for the unlock gesture:
decodeAudioData works on a suspended context, while awaiting resume() before
the first click stays pending forever and downloaded nothing.
With preferences.sound === 'off' it downloads nothing at all — a pack that
cannot be heard is bytes spent on silence. The request is REMEMBERED, not
dropped: the level is read at dispatch, so the first audible play flushes the
deferred warm, and turning sound on does not hand back a cold cache.
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,
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):
four legitimate stances, each with shipped exemplars —
'pack'— a cascade insema/components/{kebab}.tsNAMES the sound per event. Justified when the pattern ADDS meaning beyond family + intent deltas (date-field naming a fall for its clear; menubar's in-place note: "subtle commit + tap haptic for high-frequency top-level triggers"). A pack that only restates the family default is not a pack — it is noise, and the type makes that cheap to see: one word, or nothing.'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.'delegated'— composite; expression lives in the children's packs. The composite declares ONLY the events its children don't own (date-picker: its owncommit-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".- 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 pack — SEMA_MAP.sounds
A component names a sound. It never writes a parameter. The whole authoring surface of the sound channel is one word, and only when the component differs from its family's default:
{ selector: onTrigger({ eventName: 'contact-activate' }), sound: 'snap' }
Sema['cascade'].sound accepts a SoundName or SILENT, and nothing else —
the law is a type, not a convention. The same holds for the morfo's per-event
overrides.sound, for overrides.cascade (the app's door) and for the per-emit
option. Nobody authors a signature inline, not even the product: bringing a new
sound means REGISTERING it (declare the name, define it once) and then naming it.
The two kinds of entry
The pack has BASES (tick) and VARIANTS (tick.threat). Resolution is two
lookups: name.intent, then bare name, then silence. So a pack that ships only
bases works whole — the intent is ignored where no variant exists — and a pack
that cares adds variants only where it matters.
The default pack is pure synthesis: it works offline, pays no fetch and
cannot 404. A sample entry (wav / mp3 / ogg / aac) carries its URL plus a
synthetic FALLBACK, so a missing file degrades to a designed sound rather than to
silence. Whether a name is a recipe or a file is not the component's business.
Two ways to DELIVER a file-based pack
The delivery is not part of the contract. A pack is
Partial<Record<SoundName, NamedSound>> either way, hot-swappable either way,
and a name that is not a SoundName is a compile error either way.
- One file per name. The pack points at URLs (
sample('/sounds/tick.mp3', …)). The engine's cache is keyed by URL, so each file is fetched and decoded once; the browser caches each independently, andpreload: falseon an entry keeps it out of the warm-up so rare sounds load on demand. - One file for the whole pack. A JSON of base64 payloads keyed by sound
name, turned into a pack by
packFromBase64()— the entries becomedata:URIs. Measured onstatic/sounds/ui-inline.json: one request instead of fifteen, and 69.082 B under brotli against 79.332 B for the loose files, because base64's +33 % is undone by compression whileaudio/mpegis not compressed by anyone. The cost is granularity: the pack caches as one object, so one changed sound re-downloads all of them.
A data: URI is decoded IN PLACE — never fetched. A connect-src 'self' CSP
BLOCKS fetch('data:…') (the directive matches by scheme, and 'self' does not
cover data:) while letting a same-origin path through, so routing an inline
pack through fetch made it degrade silently to synthesis in exactly the
environment that made someone inline it. It is also ~6x slower.
Designed to be told apart
sound-names.test.ts fails when two entries are not separated on at least TWO
perceptual axes — a fifth of pitch, a third of brightness, half again as long,
a different contour, double the level, or 0.2 of roughness.
That guard exists because its absence was measured, by ear, by the author: a previous catalogue had five names that were the same 700 Hz note at five volumes, and every test passed. A snapshot could not have caught it — each value was exactly what the table said. The missing property was a RELATION between entries, and that is what the guard asserts.
Verbs, not components
Names describe an occurrence, never a widget: open, tick, alert. A library
organised by component (button_hard, panel_expand, window_open) needs
TRANSLATING into this vocabulary, and several of its files will compete for one
name — that is normal, and the choosing is where a pack author's ear works. See
packs/ui-mp3.ts for a worked example, including what it could not use.
Samples: what a file can and cannot carry
playSample reads exactly two fields of the resolved signature: sampleUrl
and gain. There is no playbackRate and no detune. So over a recording the
intent can only move the LEVEL.
Consequence, and it is physics rather than policy: a sample that must carry
evaluative weight needs one file per intent — tick.mp3 AND
tick.threat.mp3, not one file bent two ways. With synthesis the problem does
not arise, because each variant is designed whole.
Silence is a value, not a name
SILENT is THE canonical silence — one value for the whole system, exported
from $uix/sema. Set a channel slice to it and the channel that owns the
slice ignores it and dispatches nothing downstream: no signature is
resolved, no engine is called, no context is opened.
It admits no family, no verb and no intent, because there is nothing to modulate. That is why it is not a key: a per-family silencer would need to know which base it is cancelling, which is how the catalogue ended up with three of them.
// a pack rule that does not speak in sound
{ selector: onProvider({ eventName: 'emerge-open' }), sound: SILENT }
Absorbing by construction. Intent deltas do not apply on top of SILENT
(layer 2 cannot un-silence). A later override CAN lift it by REPLACING the
slice with a full signature — declaring sound is a decision, not a delta.
Why it is a value and not a zero. Until 2026-08-06 silence was arithmetic:
{family}.silent subtracted the family's base gain so neutral landed at 0.
A zero gain is a NUMBER, and later layers move numbers. Measured on a
"silenced" toggle: an intent delta that raises roughness past the AM
threshold (risk +0.2, threat +0.4) turned the signature into a raw tremolo
at −13.9 dBFS (risk) and −6.7 dBFS (threat) — louder than a real
button press at −9.3. The engine had already written the rule it was breaking:
«muting DROPS the channel rather than scaling to zero, because a 0 still
buzzes» (engine.ts).
The grammar and the ban on silence-as-arithmetic are enforced by
src/uix/sema/sounds-grammar.test.ts, which reads the families from event.ts
and the base gains from sema-map.ts rather than restating them; the runtime
half (SILENT reaches no engine) is pinned in chans/sound.test.ts. The guard
exists because form.* named seven form controls truthfully on 2026-05-19, was
extended to menus and trees a week later, and reached 50 packs — one of which
was the Form — before anyone noticed, since no check ever looked at these keys.
Channels and signatures
| Channel | Consumed slice | Behavior when not applicable |
|---|---|---|
visual |
effective.hold for the hold |
Falls back to the family table (SEMA_DURATIONS) or defaultHold |
sound |
effective.sound (skips if 'sound' not in activeChannels) |
no-op |
haptic |
effective.haptic (Vibration API, honors prefers-reduced-motion) |
no-op if there is no navigator.vibrate |
| (custom) | declaration merging of SemaChannelSignatures |
read by the registered channel |
The visual channel's attr namespace
VisualChannel.prepare() projects only attributes under the
data-event-* prefix:
| Attr | When |
|---|---|
data-event |
always |
data-event-id |
always |
data-event-phase |
always ('active') |
data-event-family |
if signal.family |
data-event-intent |
if signal.intent |
data-event-direction |
if signal.direction |
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:
signal.hold— imperative per-call override.effective.hold— composed by the resolver fromresolveHoldsByIntent(family, intent)(holds.ts,SEMA_HOLDS_BY_INTENT— the ONE canonical hold source; the per-familySEMA_MAP.families[*].holdfield was REMOVED 2026-07-06 because it duplicated this table and had drifted from the book's regions).- The same canonical table, consulted directly by the channel — defense when a custom map's resolver path didn't compose a hold.
- 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 setsopenin the HANDLER (Popover, Dialog and Drawer'semerge-open) MUST declaresequence: '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'ssetTimeoutturn (the "setTimeout handler took N ms" violation). Same doctrine as the checkbox-lag fix. Closing (emerge-close) stays'pre': there the element exists and the signal MUST precede the unmount.
// 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
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']
}
]
}
});
// 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: 'signal-alert-close-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.)
// 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: 'emerge-dismiss' }), sound: { sampleUrl: '/dismiss.wav' } }
// Vary by event prefix
{ selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'emerge-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(EngineSound) — they were ~63% of a 407-line "channel" and are machinery, not perceptual doctrine. Same move$motionmade 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
preparegate (does this signal admit sound at all?), - the per-channel reduction policy (BK-REDUCTIONS):
offsilences (meaning migrates viaSEMA_MIGRATION.sound→ presence / live region),reduceattenuates, - its voice —
SEMA_SOUND_VOICEinsounds.ts, the synthesis calibration sema's vocabulary was tuned against, registered on the engine at channel construction (redesign 2026-07-31, D-SR.3 inPLAN-sound-redesign.md; it was baked into the art as constants before). Earcons play on the engine'suibus with that voice — so UI-sound policy (BK-REDUCTIONS, resolved here and handed over asgainScale) can never touch the content's volume; theuibus 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 — SOUNDS (the named catalogue), 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 AudioContexts 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 admitssound, synchronously inside the user gesture. - The unlock listener (
pointerdown/mousedown/click/touchstart/keydown) is registered only AFTER the context exists, through the injected DOM surface. An engine that never plays installs no global listeners. - Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) →
roughness > 0.2inserts a tremolo stage → ADSR-lite envelope;contour(flat/ascending/descending/arc/bell) ridesosc.detune. The tremolo is a gain node IN SERIES at1 - depth, modulated on its own.gain, so the factor peaks at 1 and the trill scales WITH the envelope. It used to be patched ontoenvelope.gain, where a connection ADDS to the automation instead of scaling it: the depth stayed constant while the envelope moved, closing everysignalnote on a non-zero sample (an audible tick) and inflating acommit.subtlecharged withthreatto seven times its declared level. Pinned byengine-sound.test.ts. - A signature carrying
sampleUrlplays the sample (with anAudioBuffercache) 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
(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
emitPromise 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. All
opt-outable, all covered by unit tests.
Frequency memory — engine.frequencyMemory (default on)
BK-FREQ-MEMORY (ch. 32 §11): "la gramática debe tener memoria de frecuencia".
Repeated occurrences of the same event identity (signal.name, else
family:intent) attenuate their non-visual channels — sound.gain and
haptic.intensity — past a small threshold, down to a floor, so the 50th
autosave doesn't sound like the first. threat is exempt; the visual hold is
never touched. A window of silence resets the key to full. Distinct from the
pointer-rate throttle (that samples ONE continuous gesture; this damps repeated
discrete signals). Disable with frequencyMemory: false.
Dominance arbiter — engine.dominance (default on)
BK-DOMINANCE (ch. 30 §7): when signals overlap, the engine ranks each active
occurrence (evaluable commit/signal > structural; higher activation from
intent; recency breaks ties for the newcomer) and mutes the non-visual
channels of an incoming signal a still-active occurrence out-ranks — "lo
evaluable calla a lo estructural". threat is never muted; the visual hold and
the announce channel survive (a dominated event still reads structurally and
remains accessible). Occurrences stay active for a short overlap window. Disable
with dominance: false.
Announce channel — engine.announce (opt-in)
BK-SIGNAL-A11Y / BK-A11Y-CRITICAL: the accessible live region is now a
first-class channel (AnnounceChannel, id announce), opt-in like sound /
haptic. It reads the human-facing text from signal.message (never inferred from
the technical event name), derives priority from intent (threat/loss →
assertive, else polite), and materializes it either through an injected
announcer ({ announce }, e.g. ActiveUix.announce, one shared region) or a
self-owned pair of live regions built from the injected dom. Fire-and-forget,
never rejects. Supersedes book-deviations.md D.8's deferral.
Live-region doctrine — who owns what (AUX-1 / SEM-1, 2026-07-11). The framework has ONE sink, two emitters, and an app-level component:
-
uix.announce(ActiveUix) owns the SHARED pair of live regions — the low-level sink (its zwsp-toggle re-announce is an implementation detail of that sink, not a third policy). -
The two framework emitters both prefer delegating to it: the soma runtime's a11y commitments (
a11ySemantic.requiresLiveRegion→sources.announce) and this channel; its self-owned clear-then-set regions are ONLY the no-uix fallback.The two emitters serve different publics, and that is why nothing is announced twice: the runtime path covers components built on soma, and this channel covers an app driving
EngineSemanticWITHOUT soma, which writes its own signal and puts the text insignal.messageitself. The soma runtime deliberately does NOT forwardmessageinto the perceptual signal — its a11y commitment is already discharged throughsources.announce.✅ The delegation is now a literal (S-19, fixed 2026-08-13).
AnnounceFntakes the priority POSITIONALLY, like the sink and like soma's source, so{ announce: uix.announce }compiles as written — no adapter. It used to take an options bag, and forcing the wiring through a cast handed the sink an OBJECT where it reads a priority:liveRegionIds[obj]isundefined, so every announcement,threatincluded, landed in the polite region and stopped interrupting.✅ Both roots wire it by DEFAULT (S-19(ii), signed 2026-08-13). The composition root is the only place that holds both ends — the shared sink and the engine — and at engine-construction time
uix.announcedoes not exist yet, so any wiring an app could write fell back to the channel's self-owned regions: a SECOND live pair, the very thing this section forbids.ActiveUixtherefore registers the channel post-construction with a late-bound closure, in standalone and attach alike. Default ON because announce is SUBSTITUTION, not ornament (§channels) — the a11y announcer ships ambient, the field norm (Angular CDKLiveAnnouncer, React Aria). Opt out withevents: { announce: false }; an explicitly passedannouncewins (the root never overwrites an existing channel). Soma components still announce exactly once: their runtime keepsmessageout of the perceptual signal, so the channel no-ops for them. -
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. For sound, off governs the
NETWORK as well as the output: no sample is downloaded while it holds (see
preloadSamples above). 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) 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('emerge-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. anemerge-closewhose element unmounts after the pulse — see thesequence: 'post'note under Hold).
Slider and Drawer go through trigger exclusively — handle-pick,
handle-drag, commit-set on the slider; handle-pick,
handle-drag-progress, handle-drop 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,handle-drag-progress) issequence: '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. (coincidentis emit-then-handler, likepre; the two are just declared indivisible.) - The commit (
commit-set) issequence: 'post'— the handler settles the resolved value first, then the signal celebrates it. The move is felt continuously; the commit is felt once, after. (postis handler-then-emit; see thesequencecomment inruntime.svelte.tsand 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
queueHandleDragstores the pending value, schedules onerequestFrame, and inside it skips the emit when less thanSliderProvider.DRAG_SIGNAL_MShas elapsed since the last signal. - Drawer's
flushDragProgressdoes the same withDRAG_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 per-emit payload through trigger's overrides —
since the 2026-08-06 redesign, a sound NAME ('step': the gesture sounds by
REPETITION, so the cadence of the drag is the cadence of the ratchet) plus the
HAPTIC primitives derived from pointer velocity. The gesture resolvers that
used to synthesise pitch/gain/contour from position+velocity died with that
trade-off and were retired on 2026-08-12 (ledger
docs/process/AUDIT-docs-code-ledger.md §D10) — nothing authors sound
parameters per-emit any more. For the 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
sound(it would replace the per-emit name on every frame) nor the per-intent hapticintensity(it would flatten the live velocity curve to a constant buzz). The rule enables the channels and adds character — it never fixes the gesture's payload. (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§4 is the single source for this policy. The shape below mirrorsSEMA_FAMILY_POLICYintypes.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:
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'(onlycommitandsignal) →intentis MANDATORY inMorfoEventSemantic.'forbidden'is reserved; no family uses it today.intentGuidance('expected' | 'contextual' | 'discouraged') — a doctrinal hint with no type effect.commit/signalare'expected';contactis'discouraged'(book ch. 22 §11: "strong intent should not live in the contact"); the rest are'contextual'.
How it is enforced:
-
Compile time —
SemaEventandMorfoEventSemanticare discriminated unions derived from theintentRequirementaxis. Flipping a family from'optional'to'required'forces every morfo of that family to declare an intent or fail the typecheck. -
Runtime —
validateSemaEventthrows when an event of a family withintentRequirement: '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§6 +verbs.tsare 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 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 MUST follow the {family}-{verb}[-{nuance}] shape
(guaranteed by validateMorfo) so sema/sound/haptic can subscribe by verb and
eidos can write transversal selectors ([data-event^="emerge-dismiss"]).
The literal table used to live here, and it rotted. It was missing
commit.unselectandhandle.zoom— both live inverbs.tsand both used by real morfos. A copy of a closed set is a copy that goes stale; the generated enumeration incanon/vocabularies.mdis regenerated from the const bynpm run docs:vocabulariesand guarded for freshness bynpm run docs:check. Removed 2026-08-06.
Verbs that look like one family but belong to another per the canon:
select/toggle/acknowledge are commit (they fix state); edit is
expressed as shift.enter-mode (it changes the regime — there is no edit
verb in handle).
Defined in verbs.ts:SEMA_VERBS.
Naming shapes
A morfo.events[].name takes ONE canonical shape — the name declares the
family, and validateMorfo rejects any that does not:
// {family}-{verb}[-{nuance}] — head is the family, then the canonical verb,
// then the nuance when the component needs one.
'emerge-dismiss'; // family=emerge, verb=dismiss
'emerge-dismiss-outside'; // + nuance (outside)
'commit-toggle'; // family=commit, verb=toggle
'commit-save'; // family=commit, verb=save
validateEventName(name) parses a name — it still accepts the retired
bare-verb shape — 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}[-{nuance}] even though the verb is canonical. A temporary
allowlist in the script covers deliberate divergences.
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
Morfodeclares the component's semantic events inmorfo.eventsProviderdecides when they occur and callssemantic.emit(...)SomaRuntimeorchestrates theprewrite -> emit -> handler -> effectssequenceSemacontributes the vocabulary, normalization and domain validation, and publishes the occurrences
Dependencies
EngineSemanticwrites no attributes directly. It orchestrates channel hooks (prepare,handle,cleanup) without knowing the DOM attrs.- Each channel manages its own modality:
VisualChannel.prepare()projectsdata-event-*and then holds the perceptual window.DomSignalProjectoris the DOM writer used by the visual channel; it writes through theActiveDomreceived fromActiveUix.- Other channels (sound, haptic) access their respective APIs
(
AudioContext,navigator.vibrate, etc.).
- In normal use,
ActiveUixinjects theActiveDomintoEngineSemantic. Using Sema directly outsideActiveUixmust pass an explicitdom/projectoror 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 §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;
the ruling vocabulary is CANON.md.