44 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<void>
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 (Web Audio earcons + sample playback)
└── haptic.ts HapticChannel (Vibration API + categorical kinds)
The engine does not mutate DOM attributes directly: it runs the generic
channel.prepare(...) hook. In the visual channel that hook delegates the
projection to a SignalProjector. When ActiveUix builds it, that projector
writes through UIX's ActiveDom. The engine resolves each signal into an
EffectiveSignature with hold, sound, haptic and future typed
channels, and dispatches (signal, effective) to each channel. Each channel
reads its slice (effective.sound for audio, effective.haptic for
vibration, etc.) or ignores the signature if it doesn't use it. Only the
visual channel blocks the caller with the perceptual hold; the rest are
fire-and-forget.
Resolver and sema-map — the resolution cascade
On every emit, the engine:
- Runs the channels'
preparehooks. TheVisualChannelprojects the semantic tokensdata-event,data-event-family,data-event-intent,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):
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.
- 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:
- Layer 2 (intent.deltas):
pitch: -200means "subtract 200 from the base pitch". Compositional modifiers. - Layers 3, 4, 5a/5b (overrides):
pitch: 720means "set pitch to 720". Like CSS —gain: 0.4doesn'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
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="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:
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, 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,
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}.tstunes 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").'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 repository — sounds.ts
Components must not declare full sound signatures in every pack. Sema has a nominal repository:
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:
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:
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 (Popoverpresent, Dialogopen, Drawerpresent) 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 (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: '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.)
// 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
Short earcons synthesized via the Web Audio API from effective.sound
(pitch / centroid / roughness / attack / decay / duration / contour / gain).
Details:
- A single
AudioContextwith a masterGainNodeper engine. - Prepare-time priming, no constructor side effect. The channel creates +
resumes the
AudioContextinprepare()when the signal admitssound, synchronously inside the user gesture. - After creating the context, it registers a
click/touchstart/keydownlistener via the injected DOM surface (ActiveDom.listen(ActiveDom.getDocument(), ...)) to re-resume after passive suspends (tab switch, etc.). If the channel never prepares an audible signal, it installs no global listeners. - Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) →
ADSR-lite envelope. If
roughness > 0.2, a fast AM modulator. contour(flat/ascending/descending/arc/bell) is applied viaosc.detune.- If the signature carries a
sampleUrl, it plays the sample (with anAudioBuffercache) instead of synthesizing. - Any failure (no AudioContext, decode failure) is absorbed — sema is ornamental.
The prepare-time priming pattern applies in general to any channel whose
backend has a "first time must happen inside a gesture" restriction: audio,
vibration, fullscreen, clipboard write. Documented as convention 12 in
guia-semantica-historica.md
(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 (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) 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. aclosewhose 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; 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) 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 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 hapticintensity). Those are exactly what the per-emitoverridescontrol; 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§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 should follow the {family}-{verb}[-{variant}] shape
so sema/sound/haptic can subscribe by verb and eidos can write transversal
selectors ([data-event^="dismiss"]).
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.
Naming shapes
A morfo.events[].name can take two canonical shapes:
// 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.
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.