You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/architecture/sema.md

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 — and intentGuidance ('expected' | 'contextual' | 'discouraged') — a doctrinal hint for lint/tooling
  • normalization between the structured shape and the canonical label
  • minimal domain validation
  • EngineSemantic as the channel registry + occurrence dispatch

Sema does not decide which event happened. The provider decides. EngineSemantic receives the occurrence and dispatches it to the registered perceptual channels. Each channel materializes the signal in its modality (DOM, audio, vibration).

What it no longer is

Sema is no longer a monolithic multimodal runtime.

EngineSemantic does not contain:

  • a global accessibility policy
  • a cross-channel perceptual map
  • decisions about which modal effect to apply

Those live in each channel separately:

  • EngineSemantic keeps a channel registry and dispatches each signal
  • the DOM projector materializes the signal as data-event* in the DOM
  • SoundChannel, HapticChannel and future modal engines register as independent channels that receive the signal and decide how to materialize it on their plane

Channel registry — open

The channel set is NOT closed. The framework ships canonical signatures for sound and haptic. The visual channel exists as the projection/hold meta-channel but has no slice of its own in EffectiveSignature. Apps can add channels with a signature via declaration merging:

// 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:

  1. VisualChannel.prepare() projected data-event* onto the DOM
  2. the attributes lived in the DOM for the configured hold
  3. the attributes were already removed (cleanup complete)
  4. the Promise resolves

This is sequential strict: the caller applies the structural commit AFTER the perceptual signal has finished. There is no parallelism between the event and the state change.

Non-visual channels (sound, haptic) are fire-and-forget: they start in parallel with the visual one but do not affect the Promise's timing.

emit's internal lifecycle

1. The engine generates the occurrence's id/session
2. The engine runs `channel.prepare(...)` on the registered channels
   - `VisualChannel.prepare()` projects `data-event*` for the cascade
3. The engine resolves the cascade and dispatches the signal to ALL registered channels
   - non-visual channels (sound, haptic) → fire-and-forget (not awaited)
   - the visual channel → awaited
4. The engine resolves the Promise when the visual channel has finished
   (strict sequential semantics: cleanup BEFORE the resolve)

Channels as modules

Sema is organized in symmetric perceptual channels:

src/uix/sema/
├── engine.ts             registry + channel prepare/dispatch + cascade composition
├── resolver.ts           resolveSignature(signal, opts): EffectiveSignature
├── stamp.ts              stampEventAttrs / unstampEventAttrs (data-event-*)
├── channels.ts           channel ids, signatures and override types
├── sounds.ts             nominal sound repository + dynamic recipes
├── sema-map.ts           SEMA_MAP data + per-component Sema packs
├── components/           per-component perceptual packs (CSEM)
│   ├── dialog.ts         dialogSema — cascade rules + preloadSamples
│   ├── toast.ts          (future)
│   └── …
└── chans/
    ├── types.ts          Channel interface — handle(signal, effective)
    ├── visual.ts         VisualChannel (data-event projection + hold)
    ├── sound.ts          SoundChannel (gate + reduction policy; the Web Audio
    │                     runtime lives in the `$sound` art, injected)
    └── haptic.ts         HapticChannel (Vibration API + categorical kinds)

The engine does not mutate DOM attributes directly: it runs the generic channel.prepare(...) hook. In the visual channel that hook delegates the projection to a SignalProjector. When ActiveUix builds it, that projector writes through UIX's ActiveDom. The engine resolves each signal into an EffectiveSignature with hold, sound, haptic and future typed channels, and dispatches (signal, effective) to each channel. Each channel reads its slice (effective.sound for audio, effective.haptic for vibration, etc.) or ignores the signature if it doesn't use it. Only the visual channel blocks the caller with the perceptual hold; the rest are fire-and-forget.

Resolver and sema-map — the resolution cascade

On every emit, the engine:

  1. Runs the channels' prepare hooks. The VisualChannel projects the semantic tokens data-event, data-event-family, data-event-intent, data-event-direction, data-event-phase, data-event-id onto signal.target. These are the tokens the cascade and eidos's CSS read.
  2. Calls resolveSignature(signal, opts), which applies the cascade (canonical numbering 1 · 2 · 3 · 4 · 5a · 5b — the same in engine.ts, resolver.ts and CLAUDE.md; each layer overrides the previous):
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.

  1. Dispatches to each channel with the resolved signature.
  2. Awaits the VisualChannel (which contributes the hold).
  3. 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.2 adds to the base. Modifiers.
  • Layers 3, 4, 5a/5b (overrides): intensity: 0.7 sets. 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:

  • partKebab is checked against morfo.parts[].kebab.
  • eventName is checked against morfo.events[].name.
  • eventFamily / eventIntent are typed against the canonical sema unions.
  • state / aria / pseudo accept plain strings (the data-attr vocabulary is per-component and not yet typed-derived).

Renaming a part or event in morfo breaks the cascade at type-check time, not silently in production. Hand-written selector strings in cascade rules are a code smell — review them as drift.

Per-component packs — sema/components/{name}.ts

Each component ships its perceptual-defaults pack in src/uix/sema/components/{name}.ts, symmetric to soma and eidos:

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 —

  1. 'pack' — a cascade in sema/components/{kebab}.ts NAMES 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.
  2. 'family-default' — family base + intent deltas suffice. Legitimate when (a) the gesture is generic contact (button), (b) the book itself counsels restraint (command, citing ch. 22 §8: celebrating at the click is "celebrate before time" — the outcome fires downstream), or (c) a composed child supplies the character (field-langs: the embedded ToggleGroup's pack puts the tap). The reason is written IN the morfo.
  3. 'delegated' — composite; expression lives in the children's packs. The composite declares ONLY the events its children don't own (date-picker: its own commit-reset; selection/commit sound through the embedded date-field/calendar). The anti-duplication rationale is radio-cards' morfo header: "declaring them here would duplicate the contract".
  4. Declared debt — events: [] with an honest "yet" comment (the generic Picker) is better than silence but MUST carry a deadline or become a decision: an undated "yet" is a hole in disguise.

Coherence is guarded: morfo-vocabulary-check fails when a pack file exists and expression declares anything other than 'pack', and warns when a pack exists with no expression at all (verdict S11d).

The sound 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, and preload: false on 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 become data: URIs. Measured on static/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 while audio/mpeg is 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:

  1. signal.hold — imperative per-call override.
  2. effective.hold — composed by the resolver from resolveHoldsByIntent(family, intent) (holds.ts, SEMA_HOLDS_BY_INTENT — the ONE canonical hold source; the per-family SEMA_MAP.families[*].hold field was REMOVED 2026-07-06 because it duplicated this table and had drifted from the book's regions).
  3. The same canonical table, consulted directly by the channel — defense when a custom map's resolver path didn't compose a hold.
  4. The channel's global defaultHold (brief, 240ms by default).

See holds.ts (SEMA_HOLDS_BY_INTENT) for the concrete family+intent values.

The hold timer runs on the managed scheduler, not on raw setTimeout. The engine receives timers (in ActiveUix it is uix.timers) and forwards it to the three channels: the VisualChannel's hold, the HapticChannel's delay and the SoundChannel's earcon duration are scheduled via uix.timers.schedule(...) (the semaDelay helper in src/uix/sema/timers.ts). That makes perceptual timing cancelable on dispose, observable, and deterministic under a fake clock in tests. It only falls back to setTimeout when a channel is built without a scheduler (direct unit tests); production always injects the scheduler.

Overlay openings — sequence: 'post', not 'pre'. An appearance event whose provider sets open in the HANDLER (Popover, Dialog and Drawer's emerge-open) MUST declare sequence: 'post'. With 'pre' the runtime awaits the emit — and therefore the ~240ms hold — BEFORE the handler, gating the content mount behind the hold: the overlay opens late and its first render lands inside the hold's setTimeout turn (the "setTimeout handler took N ms" violation). Same doctrine as the checkbox-lag fix. Closing (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 $motion made out of eidos, for the same reason and with the same result: the art owns the RUNTIME, the layer keeps its DATA and doctrine.

What the channel keeps — all of it doctrine:

  • the prepare gate (does this signal admit sound at all?),
  • the per-channel reduction policy (BK-REDUCTIONS): off silences (meaning migrates via SEMA_MIGRATION.sound → presence / live region), reduce attenuates,
  • its voice — SEMA_SOUND_VOICE in sounds.ts, the synthesis calibration sema's vocabulary was tuned against, registered on the engine at channel construction (redesign 2026-07-31, D-SR.3 in PLAN-sound-redesign.md; it was baked into the art as constants before). Earcons play on the engine's ui bus with that voice — so UI-sound policy (BK-REDUCTIONS, resolved here and handed over as gainScale) can never touch the content's volume; the ui bus itself is the art's graph-side lever, with no direct prefs wiring today,
  • and the division of labour: the channel resolves the LEVEL, the engine applies the gain.

Everything else — 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 admits sound, synchronously inside the user gesture.
  • The unlock listener (pointerdown / mousedown / click / touchstart / keydown) is registered only AFTER the context exists, through the injected DOM surface. An engine that never plays installs no global listeners.
  • Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) → roughness > 0.2 inserts a tremolo stage → ADSR-lite envelope; contour (flat / ascending / descending / arc / bell) rides osc.detune. The tremolo is a gain node IN SERIES at 1 - depth, modulated on its own .gain, so the factor peaks at 1 and the trill scales WITH the envelope. It used to be patched onto envelope.gain, where a connection ADDS to the automation instead of scaling it: the depth stayed constant while the envelope moved, closing every signal note on a non-zero sample (an audible tick) and inflating a commit.subtle charged with threat to seven times its declared level. Pinned by engine-sound.test.ts.
  • A signature carrying sampleUrl plays the sample (with an AudioBuffer cache) and falls back to synthesis on fetch / decode failure.
  • Any failure is absorbed — sema is ornamental.

The prepare-time priming pattern applies in general to any channel whose backend has a "first time must happen inside a gesture" restriction: audio, vibration, fullscreen, clipboard write. Documented as convention 12 in guia-semantica-historica.md (historical seed).

Error policy

  • Errors in NON-visual channels are logged but never propagate. Sema is ornamental: an audio-context or vibration-API failure must not abort the provider's operation.
  • If the visual channel throws, the emit Promise rejects. The caller decides.
  • In fire-and-forget (void semantic.emit(...)), a visual rejection propagates as an unhandled promise — a conscious policy.

Perceptual arbitration, reductions and accessibility (C-series, 2026-07-04)

Landed from the Sema↔book audit (sema-findings.md, a process registry since removed from the tree); anchors in book-map.md. 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 EngineSemantic WITHOUT soma, which writes its own signal and puts the text in signal.message itself. The soma runtime deliberately does NOT forward message into the perceptual signal — its a11y commitment is already discharged through sources.announce.

    ✅ The delegation is now a literal (S-19, fixed 2026-08-13). AnnounceFn takes 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] is undefined, so every announcement, threat included, 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.announce does 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. ActiveUix therefore 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 CDK LiveAnnouncer, React Aria). Opt out with events: { announce: false }; an explicitly passed announce wins (the root never overwrites an existing channel). Soma components still announce exactly once: their runtime keeps message out 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. an emerge-close whose element unmounts after the pulse — see the sequence: 'post' note under Hold).

Slider and Drawer go through trigger exclusively — handle-pick, handle-drag, commit-set on the slider; 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) is sequence: 'coincident' — the perceptual signal and the value update are indivisible. The user is the value changing, so the signal fires alongside the mutation, neither anticipating nor trailing it. (coincident is emit-then-handler, like pre; the two are just declared indivisible.)
  • The commit (commit-set) is sequence: 'post' — the handler settles the resolved value first, then the signal celebrates it. The move is felt continuously; the commit is felt once, after. (post is handler-then-emit; see the sequence comment in runtime.svelte.ts and the overlay note under Hold.)

Slider is the worked example: its morfo declares handle-pick / handle-drag as handle · coincident and commit-set as commit · post.

Throttle continuous pointer emits; leave keyboard alone

onpointermove fires far faster than the eye or ear resolves. Continuous emits are therefore coalesced to animation frames and floored to a component-tuned minimum interval — both via ActiveDom's requestFrame (the sanctioned rAF vehicle; never a raw requestAnimationFrame or setTimeout):

  • Slider's queueHandleDrag stores the pending value, schedules one requestFrame, and inside it skips the emit when less than SliderProvider.DRAG_SIGNAL_MS has elapsed since the last signal.
  • Drawer's flushDragProgress does the same with DRAG_PROGRESS_SIGNAL_MS, deliberately set below rAF cadence (sparser than one signal per frame).

The concrete millisecond floors are perceptual tuning, not architecture — they live at those constants in the providers, never copied here.

Keyboard is the opposite case and is not throttled: Slider's onkeydown calls commitValue() → trigger('commit-set') on every arrow / Page / Home / End press. OS key-repeat is already human-paced and every repeat is a discrete committed value, so one emit per keydown is correct. Throttling is a property of sub-perceptual pointer sampling, not of continuous components in general.

The per-emit payload owns the primitives; the cascade only adds character

handle-drag carries a 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 haptic intensity (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 mirrors SEMA_FAMILY_POLICY in types.ts; if they ever disagree, the code + canon win.

The doctrine on when intent is mandatory lives in a const in src/uix/sema/types.ts. Each family declares two independent axes:

export const SEMA_FAMILY_POLICY = {
	contact: { intentRequirement: 'optional', intentGuidance: 'discouraged' },
	commit: { intentRequirement: 'required', intentGuidance: 'expected' },
	signal: { intentRequirement: 'required', intentGuidance: 'expected' },
	handle: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	emerge: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	shift: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	sustain: { intentRequirement: 'optional', intentGuidance: 'contextual' },
	delegate: { intentRequirement: 'optional', intentGuidance: 'contextual' }
} as const;
  • intentRequirement ('required' | 'optional' | 'forbidden') — the compile-time gate that shapes the discriminated union. 'required' (only commit and signal) → intent is MANDATORY in MorfoEventSemantic. 'forbidden' is reserved; no family uses it today.
  • intentGuidance ('expected' | 'contextual' | 'discouraged') — a doctrinal hint with no type effect. commit/signal are 'expected'; contact is 'discouraged' (book ch. 22 §11: "strong intent should not live in the contact"); the rest are 'contextual'.

How it is enforced:

  1. Compile time — SemaEvent and MorfoEventSemantic are discriminated unions derived from the intentRequirement axis. Flipping a family from 'optional' to 'required' forces every morfo of that family to declare an intent or fail the typecheck.

  2. Runtime — validateSemaEvent throws when an event of a family with intentRequirement: 'required' is built without intent (a defense against malformed morfos or external inputs).

Why this policy replaced the valenced/transitional split:

The original canonical doctrine assumed only valenced families (contact, commit, signal, handle) could declare intent. Transitional ones (emerge, shift, sustain) were intent-less by definition. UX reality contradicted it: a Dialog opening to confirm a destructive delete carries threat in its very appearance. The policy distinguishes by the practical NEED for intent, not by taxonomic category.

The valenced/transitional distinction still exists as a classification, but it no longer dictates the intent rules — the policy does.

The canonical verb vocabulary (SEMA_VERBS)

Canonical: CANON.md §6 + verbs.ts are the source of truth for the verb vocabulary. This section documents how sema consumes it (validation, naming shapes).

Cross-component action verbs grouped by family. The canon lives in verbs.ts 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.unselect and handle.zoom — both live in verbs.ts and both used by real morfos. A copy of a closed set is a copy that goes stale; the generated enumeration in canon/vocabularies.md is regenerated from the const by npm run docs:vocabularies and guarded for freshness by npm 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

  • Morfo declares the component's semantic events in morfo.events
  • Provider decides when they occur and calls semantic.emit(...)
  • SomaRuntime orchestrates the prewrite -> emit -> handler -> effects sequence
  • Sema contributes the vocabulary, normalization and domain validation, and publishes the occurrences

Dependencies

  • EngineSemantic writes no attributes directly. It orchestrates channel hooks (prepare, handle, cleanup) without knowing the DOM attrs.
  • Each channel manages its own modality:
    • VisualChannel.prepare() projects data-event-* and then holds the perceptual window.
    • DomSignalProjector is the DOM writer used by the visual channel; it writes through the ActiveDom received from ActiveUix.
    • Other channels (sound, haptic) access their respective APIs (AudioContext, navigator.vibrate, etc.).
  • In normal use, ActiveUix injects the ActiveDom into EngineSemantic. Using Sema directly outside ActiveUix must pass an explicit dom/projector or choose a documented degradation.

Architecture rule

Morfo authorizes the component's semantics.

Sema defines the canonical vocabulary and dispatches signals to channels.

Provider decides when to emit.

Each Channel materializes the signal in its modality.

See architecture/overview.md §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.

Powered by TurnKey Linux.