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

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

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

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

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

emit's internal lifecycle

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

Channels as modules

Sema is organized in symmetric perceptual channels:

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

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

Resolver and sema-map — the resolution cascade

On every emit, the engine:

  1. Runs the channels' prepare hooks. The VisualChannel projects the semantic tokens data-event, data-event-family, data-event-intent, data-event-phase, data-event-id onto signal.target. These are the tokens the cascade and eidos's CSS read.
  2. Calls resolveSignature(signal, opts), which applies the cascade (canonical numbering 1 · 2 · 3 · 4 · 5a · 5b — the same in engine.ts, resolver.ts and CLAUDE.md; each layer overrides the previous):
1.  FAMILY base       — SEMA_MAP.families[signal.family].base
                        sound / haptic + activeChannels + hold
2.  INTENT deltas     — SEMA_MAP.intents[signal.intent] when present;
                        numbers ADD by default — they are modifiers
3.  MORFO overrides   — signal.overrides + signal.channels
                        (numbers REPLACE by default — they are set values)
4.  RUNTIME overrides — engineOpts.overrides.runtime (path-based globals;
                        baked into the map in the constructor; numbers REPLACE)
5a. PACK cascade      — engineOpts.components (per-component packs)
5b. APP cascade       — engineOpts.overrides.cascade (appended after the
                        packs; wins specificity ties by declaration order).
                        CSS-like selectors against signal.target with the
                        data-event-* already stamped; numbers REPLACE.
  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:

  • Layer 2 (intent.deltas): pitch: -200 means "subtract 200 from the base pitch". Compositional modifiers.
  • Layers 3, 4, 5a/5b (overrides): pitch: 720 means "set pitch to 720". Like CSS — gain: 0.4 doesn't add, it assigns.
  • To add explicitly from an override layer: { op: 'add', value: 100 }.
  • To multiply: { op: 'multiply', factor: 1.2 }.
  • To replace non-numeric primitives: { op: 'replace', value: ... }.

If signal.family is missing or not in the map, it returns an empty EffectiveSignature — the channels no-op.

Semantic tokens — data-event-*

VisualChannel.prepare() projects the following attrs onto signal.target BEFORE the cascade resolves. Rules with selectors over these attrs match natively via target.matches() / target.closest():

Attr Value Origin
data-event 'close-after-fail' signal.name
data-event-family 'signal' signal.family
data-event-intent 'threat' signal.intent (when present)
data-event-phase 'active' while the hold lasts
data-event-id 'sig-42' occurrence id

Those tokens are the cross-channel contact surface: sema's cascade (sound, haptic and future channels) reads them with CSS selectors, the same way eidos's CSS reads them to tint borders / animate states during the hold. One perceptual surface, separate owners.

Cascade rules — flat CSS-like shape

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:

  • 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, 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 —

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

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

The sound repository — sounds.ts

Components must not declare full sound signatures in every pack. Sema has a nominal repository:

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:

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

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

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

Overlay openings — sequence: 'post', not 'pre'. An appearance event whose provider sets open in the HANDLER (Popover present, Dialog open, Drawer present) MUST declare sequence: 'post'. With 'pre' the runtime awaits the emit — and therefore the ~240ms hold — BEFORE the handler, gating the content mount behind the hold: the overlay opens late and its first render lands inside the hold's setTimeout turn (the "setTimeout handler took N ms" violation). Same doctrine as the checkbox-lag fix. Closing (close) stays 'pre': there the element exists and the signal MUST precede the unmount.

// 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 AudioContext with a master GainNode per engine.
  • Prepare-time priming, no constructor side effect. The channel creates + resumes the AudioContext in prepare() when the signal admits sound, synchronously inside the user gesture.
  • After creating the context, it registers a click / touchstart / keydown listener via the injected DOM surface (ActiveDom.listen(ActiveDom.getDocument(), ...)) to re-resume after passive suspends (tab switch, etc.). If the channel never prepares an audible signal, it installs no global listeners.
  • Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) → ADSR-lite envelope. If roughness > 0.2, a fast AM modulator.
  • contour (flat / ascending / descending / arc / bell) is applied via osc.detune.
  • If the signature carries a sampleUrl, it plays the sample (with an AudioBuffer cache) instead of synthesizing.
  • Any failure (no AudioContext, decode failure) is absorbed — sema is ornamental.

The prepare-time priming pattern applies in general to any channel whose backend has a "first time must happen inside a gesture" restriction: audio, vibration, fullscreen, clipboard write. Documented as convention 12 in guia-semantica-historica.md (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 (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. a close whose element unmounts after the pulse — see the sequence: 'post' note under Hold).

Slider and Drawer go through trigger exclusively — handle-pick, handle-drag, commit-set on the slider; drag-start, drag-progress, drag-end on the drawer. Every continuous one is void.

coincident vs post for a moving value

A continuous gesture has two temporally distinct moments, and the morfo encodes each with its sequence:

  • The move (handle-drag, drag-progress) is sequence: 'coincident' — the perceptual signal and the value update are indivisible. The user is the value changing, so the signal fires alongside the mutation, neither anticipating nor trailing it. (coincident is emit-then-handler, like pre; the two are just declared indivisible.)
  • The commit (commit-set) is sequence: 'post' — the handler settles the resolved value first, then the signal celebrates it. The move is felt continuously; the commit is felt once, after. (post is handler-then-emit; see the sequence comment in runtime.svelte.ts and the overlay note under Hold.)

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

Throttle continuous pointer emits; leave keyboard alone

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

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

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

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

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

handle-drag carries a dynamic sound payload — pitch / gain / contour derived from pointer position and velocity — passed per-emit through trigger's overrides. For that live payload to survive frame after frame, the component's sema pack for the continuous event sets only channels: ['sound', 'haptic'] and overrides nothing else (sema/components/slider.ts).

A cascade rule for a continuous event MUST NOT set pitch / gain / contour (nor per-intent haptic intensity). Those are exactly what the per-emit overrides control; a rule that also set them would clobber the live payload on every frame and flatten the gesture to a constant tone. The rule enables the channels and adds character — it never fixes the evaluative primitives. (Same division as Override layers and the canon rule "cascade rules add character, never the intent's evaluative profile".)

The per-family intent policy — SEMA_FAMILY_POLICY

Canonical: CANON.md §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 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

  • 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.