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/src/uix/soma/runtime.svelte.ts

789 lines
31 KiB

/**
* SomaRuntime — per-instance interpreter of a component's morfo within
* the headless layer.
*
* Lives in soma (not morfo) because:
* 1. Morfo is pure declarative TypeScript (DNA). It cannot host runtime
* that imports Svelte runes or reaches into other layers (sema, adom).
* 2. The runtime is consumed exclusively by soma providers. Co-locating
* consumer + tool keeps the layer surface minimal.
* 3. Naming: this is the runtime OF soma (its execution arm), not OF
* morfo (which is declaration only). Per the doctrine
* "morfo declares, soma executes".
*
* Surface:
* - `part(part, opts)` — high-level part handle for concrete provider
* classes: registration, context publication, id/marker/ref props and
* contract helpers. When `syncAttrs` is true, the runtime also owns the
* part's morfo-derived DOM attrs through `dom.apply`.
* - `partProps(part)` — returns ONLY the part's static identity (id, marker,
* ref attachment). Mutable attrs (data-state, aria-*, etc.) are written to
* the DOM by the runtime's effect, never via Svelte render.
* - `keydown(part, event)` — dispatch from `morfo.keyboard`.
* - `trigger(eventName)` — orchestrates prewrite + eventEngine.emit + handler.
*
* Operational rules:
* - `partProps` must not include any state-derived attr — that would race
* with `dom.apply`. The boundary is identity vs. derivation.
* - `part(..., { syncAttrs: true })` must be called inside an effect root
* (a Svelte component scope or a class constructor invoked during component
* init), because the per-part effect uses `$effect`.
* - Effects are best-effort idempotent: each tick reapplies the full attr
* map for that part. Missing attrs are removed via `dom.apply`'s
* `false/null/undefined` handling.
*
* Sema is ornamental:
* - `sources.eventEngine` is OPTIONAL. If absent, `trigger` runs prewrite +
* handler + effects but skips the perceptual emit. Components stay
* functional (state, a11y, keyboard) without an `EngineSemantic`.
* - `target` is only required when emit will actually run. With no engine,
* trigger doesn't throw on missing DOM target.
*/
import { tick, untrack } from 'svelte';
import type { ActiveDom, DomAttrValue } from '$adom';
import type { Logger } from '$libs/logger';
import {
resolveSemaDuration,
resolveIntent,
type EngineSemantic,
type SemaChannelId,
type SemaSignatureOverride,
type SignalPersistence
} from '$uix/sema';
import type { Morfo, MorfoSemanticIntent } from '$uix/morfo';
import {
assertContract,
evalAttrPlan,
isPolymorphicSemantic,
registerMorfo,
type CompiledPart,
type ParsedKey
} from '$uix/morfo';
import type { Intent } from '$uix/intent';
import type { SemaFamily } from '$uix/sema/types';
import { attachRef, type RefAttachment, type Active, type State } from '$libs/reactive';
import { shouldEmitMorfoEntry, type MorfoBindings } from '$uix/morfo';
import {
SomaRuntimeEventError,
SomaRuntimePartError,
SomaRuntimePolymorphicError,
SomaRuntimeTargetError
} from './errors';
/**
* Minimum perceptual event surface the runtime depends on. Real consumers pass an
* `EngineSemantic`; tests pass a fake with the same shape. Keeps the
* runtime independent of the full engine surface (channels registry,
* dispose, etc.).
*
* `clear` and `clearTarget` are optional so test fakes that only implement
* `emit` keep type-checking. When absent, `runtime.clearSignal` /
* `runtime.clearTarget` become no-ops (and the persistent signal projection
* just lingers — fine for tests, never seen in production where the real
* `EngineSemantic` always provides them).
*/
export type EventEngineEmitter = Pick<EngineSemantic, 'emit'> &
Partial<Pick<EngineSemantic, 'clear' | 'clearTarget'>>;
export type SourceMap = Record<string, () => unknown>;
/**
* Provider-supplied event handler. Synchronous by V1 contract — async work
* happens in the call-site before invoking `runtime.trigger(eventName)`.
* Returning a value is allowed; it is ignored by the runtime.
*/
export type EventHandler = () => unknown;
/**
* Provider-supplied keyboard action handler. Receives the original event
* (so the action can `preventDefault()` selectively) and runs synchronously.
*/
export type KeyboardActionHandler = (event: KeyboardEvent) => void;
export interface SomaRuntimeSources {
/** DOM service. Required — the runtime applies attrs through it. */
dom: ActiveDom;
/**
* Perceptual event engine. Optional — sema is ornamental per the doctrine.
* When present, `trigger()` emits the perceptual signal between
* prewrite and handler. When absent, that step is skipped silently;
* components remain functional without it.
*/
eventEngine?: EventEngineEmitter;
/** Component-wide state sources. Read by `stateRef` declarations. */
states?: SourceMap;
/** Component-wide prop sources. Read by `propRef` declarations. */
props?: SourceMap;
/** Component-wide part-id sources. Read by `partRef` declarations. */
parts?: SourceMap;
/**
* Provider's per-event handlers. Each is called by `runtime.trigger` after
* `eventEngine.emit` resolves and is responsible for mutating internal state.
*/
events?: Record<string, EventHandler>;
/**
* Provider's per-action handlers, dispatched by `runtime.keydown` against
* the part's `morfo.keyboard` declarations. Action names match the
* `action` field on each `MorfoKeyboard` entry (e.g. `'toggle'`, `'close'`).
*/
actions?: Record<string, KeyboardActionHandler>;
/** Translation lookup. Read by `translationRef` declarations. */
translate?: (key: string) => string | undefined;
/**
* Live-region bridge. Called by `trigger()` when a morfo event declares
* `a11ySemantic.requiresLiveRegion: true` and the caller passes a
* `message` in `TriggerOptions`. Soma's standard runtime wires this to
* `ActiveUix.announce(...)`; tests can pass a fake or omit entirely.
*/
announce?: (message: string, priority?: 'polite' | 'assertive') => void;
/** Optional diagnostics logger for runtime contract checks. */
logger?: Logger;
}
export interface SomaRuntimePartBaseOpts {
/** Per-instance id of this part (from the part-provider's opts). */
id: Active<string>;
/** Per-instance ref. Required for parts with DOM. */
ref?: State<HTMLElement | null>;
/** Optional callback when the ref attaches/detaches. */
onRefChange?: (el: HTMLElement | null) => void;
/** Per-part state sources (override component-level for this part only). */
states?: SourceMap;
/** Per-part prop sources (override component-level for this part only). */
props?: SourceMap;
/** Per-part part-id sources (override component-level for this part only). */
parts?: SourceMap;
}
export interface SomaRuntimePartContext<T = unknown> {
set(value: T): unknown;
}
export interface SomaRuntimePartOpts<Owner = unknown> extends SomaRuntimePartBaseOpts {
/** Concrete provider instance to publish into context. */
owner?: Owner;
/** Optional context where the provider instance should be registered. */
context?: SomaRuntimePartContext<Owner>;
/**
* When true, this part's morfo attrs are synchronized to the DOM by the
* runtime effect. Keep false for providers that still author their
* state-derived attrs in render props.
*/
syncAttrs?: boolean;
}
export interface SomaRuntimePart {
readonly attachment: RefAttachment | undefined;
/** Static render identity: id, marker attr, archetype and ref attachment. */
readonly props: Record<string, unknown>;
/** Resolve morfo static/dynamic attrs for render-time legacy/manual props. */
resolveProps(bindings?: MorfoBindings): Record<string, unknown>;
/**
* Static identity + every morfo-declared attr resolved against THIS part's
* registered sources. Spread this in a part's `props` getter, then add only
* the soma-specific extras (handlers, formatted overrides, native attrs).
* The canonical alternative to hardcoding role/aria/data in the provider.
*/
renderProps(): Record<string, unknown>;
/** Validate an authored prop bag against the morfo data contract. */
assert<P extends Record<string, unknown>>(props: P): P;
}
export interface SomaRuntime {
part<Owner = unknown>(part: string, opts: SomaRuntimePartOpts<Owner>): SomaRuntimePart;
partProps(part: string): Record<string, unknown>;
/**
* Dispatch a keyboard event against a part's `morfo.keyboard` entries.
*
* Matches `event.key` plus modifier flags against each entry's `key` spec
* (supports `"Shift+Tab"`, `"Ctrl+A"`, etc.). On the first match, looks up
* the corresponding handler in `sources.actions[entry.action]` and calls
* it with the original event.
*
* Returns `true` if a handler ran, `false` otherwise — the consumer can
* use the result to decide whether to fall through to native behavior.
*
* The runtime never calls `preventDefault` on its own; the action handler
* decides whether to.
*/
keydown(part: string, event: KeyboardEvent): boolean;
/**
* Trigger a morfo-declared event by name.
*
* Sequence:
* 1. apply `prewrite` attrs imperatively (transient markers like
* `data-last-action`)
* 2. await `eventEngine.emit({ target, name, family, intent? })`
* 3. invoke the provider's handler from `sources.events[name]`
* 4. effects on the affected parts re-derive structural attrs from the
* new state and write them via `dom.apply` (automatic)
*
* Step 2 is skipped when `sources.eventEngine` is absent. Step 3 is skipped
* when no handler is registered for the event.
*
* Resolves only after `eventEngine.emit` has had its rAF and the handler has
* returned. Effects run on the next reactive tick, not awaited here.
*
* Returns the signal id emitted (undefined if `eventEngine` is absent or
* the event was silenced via `channels: []`). For non-transient signals
* the caller can hold this id and pass it to {@link clearSignal} when
* the underlying condition (user action, fix applied, state ended) is met.
*/
trigger(eventName: string, opts?: TriggerOptions): Promise<TriggerResult>;
/**
* Clear an active persistent signal by id. Returns `true` if cleared,
* `false` if the id wasn't active. No-op when `eventEngine` lacks
* `clear` (test fakes).
*/
clearSignal(id: string): boolean;
/**
* Clear all active persistent signals projected on the given target
* element. Returns the count of signals cleared. No-op when
* `eventEngine` lacks `clearTarget` (test fakes).
*/
clearTarget(target: HTMLElement): number;
/**
* Read the current DOM element of a registered part by its kebab name.
* Returns `null` when the part isn't registered, or when its ref hasn't
* been attached yet. Used by providers that need to call
* `clearTarget(...)` on a sub-part without holding the part's own
* reference directly. Cheaper than passing refs around or going
* through context lookups.
*/
partRef(part: string): HTMLElement | null;
}
/**
* Result of a `trigger()` call. Always returned; the caller can ignore
* unless the event is non-transient and they need to clear it later.
*/
export interface TriggerResult {
/** Signal id emitted, when `eventEngine` was present and the signal wasn't silenced. */
readonly id?: string;
/** Resolved persistence policy applied to the emitted signal. */
readonly persistence?: SignalPersistence;
}
/**
* Per-call overrides for `runtime.trigger`.
*/
export interface TriggerOptions {
/**
* Element to use as the visual signal target when the morfo's declared
* target part isn't (yet) in the DOM, or when the event belongs to one
* concrete instance of a repeated part such as `Item`. An explicit
* fallback wins over the registered part ref because the caller is
* pointing at the exact interaction surface for this emit.
*/
fallbackTarget?: HTMLElement;
/**
* Per-call signal overrides. Each runtime channel slice (`sound`,
* `haptic`, future channels, …) merges with whatever the morfo event
* declared in `semantic.overrides`; on key conflict, the per-call value
* wins (per-call > morfo-declared).
*
* Cascade rules (capas 5a/5b) STILL win on conflict — a cascade rule that
* sets `sound.pitch` will override a per-call pitch. To make a per-emit
* value the source of truth, the cascade rule should NOT set the same
* primitive (only set `channels` to activate the channel).
*
* Used for dynamic per-emit signatures, e.g. drawer drag-progress
* modulating sound by velocity / position / direction.
*/
overrides?: SemaSignatureOverride;
/**
* Per-call active-channels override. Replaces the morfo event's
* declared `semantic.channels` for this single emit. Empty array
* silences the signal entirely on this call.
*/
channels?: readonly SemaChannelId[];
/**
* Per-call persistence override (book cap. 24 §6). Replaces the morfo
* event's declared `semantic.persistence` for this single emit. Use
* sparingly — the morfo declaration is the source of truth for what
* a signal type means. Per-call overrides exist for genuine runtime
* branches (e.g. a warn signal that's transient when the user dismisses
* the field but untilFix when the form is submitted).
*/
persistence?: SignalPersistence;
/**
* Human-readable text the event represents. Consumed by the a11y
* pipeline:
* - pushed to `sources.announce(...)` when the morfo event declares
* `a11ySemantic.requiresLiveRegion`;
* - reused as the announce content when the user prefers reduced
* motion AND the event declares `reducedMotionFallback: 'text'`.
*
* Optional. When omitted, the live-region announcement is skipped
* (no point announcing an empty string).
*/
message?: string;
/**
* Polymorphic event concretion (book §5.3). When the morfo event
* declares `allowedFamilies` (its regular `family`/`verb` act as the
* default shape — additive design, LIBRO_VARIACIONES D.11), the caller
* can commit to a concrete shape here. Validated at runtime against
* `allowedFamilies` — passing a family outside the allowlist raises
* `SomaRuntimePolymorphicError`.
*
* Silently ignored for non-polymorphic events (those without
* `allowedFamilies`).
*/
semantic?: {
family: SemaFamily;
intent?: Intent | MorfoSemanticIntent;
verb?: string;
};
}
interface PartRegistration {
id: Active<string>;
ref: State<HTMLElement | null> | undefined;
attachment: RefAttachment | undefined;
states: SourceMap | undefined;
props: SourceMap | undefined;
parts: SourceMap | undefined;
bindings: RuntimeBindingsScratch;
/** Compiled part metadata so `partProps` doesn't re-walk the morfo tree. */
meta: CompiledPart;
}
interface RuntimeBindingsScratch extends MorfoBindings {
states?: Record<string, unknown>;
props?: Record<string, unknown>;
parts?: Record<string, unknown>;
}
const EMPTY_ROOT_PROPS: Record<string, unknown> = Object.freeze({});
const EMPTY_BINDINGS: MorfoBindings = Object.freeze({});
function hasSourceKeys(map: SourceMap | undefined): boolean {
if (!map) return false;
for (const _key in map) return true;
return false;
}
function createBindingsScratch(
opts: Pick<SomaRuntimePartBaseOpts, 'states' | 'props' | 'parts'>,
sources: SomaRuntimeSources
): RuntimeBindingsScratch {
return {
states: hasSourceKeys(sources.states) || hasSourceKeys(opts.states) ? {} : undefined,
props: hasSourceKeys(sources.props) || hasSourceKeys(opts.props) ? {} : undefined,
parts: hasSourceKeys(sources.parts) || hasSourceKeys(opts.parts) ? {} : undefined,
translations: sources.translate
};
}
function clearBindingsBucket(bucket: Record<string, unknown> | undefined): void {
if (!bucket) return;
for (const key in bucket) delete bucket[key];
}
function readSourcesInto(
bucket: Record<string, unknown> | undefined,
map: SourceMap | undefined
): void {
if (!bucket || !map) return;
for (const key in map) bucket[key] = map[key]();
}
function readBindings(reg: PartRegistration, sources: SomaRuntimeSources): MorfoBindings {
const bindings = reg.bindings;
clearBindingsBucket(bindings.states);
clearBindingsBucket(bindings.props);
clearBindingsBucket(bindings.parts);
readSourcesInto(bindings.states, sources.states);
readSourcesInto(bindings.props, sources.props);
readSourcesInto(bindings.parts, sources.parts);
readSourcesInto(bindings.states, reg.states);
readSourcesInto(bindings.props, reg.props);
readSourcesInto(bindings.parts, reg.parts);
bindings.translations = sources.translate;
return bindings;
}
export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): SomaRuntime {
const compiled = registerMorfo(morfo);
const registrations = new Map<string, PartRegistration>();
const rootPropsScratch = hasSourceKeys(sources.props) ? {} : undefined;
function resolvePart(part: string): CompiledPart {
const compiledPart = compiled.parts.byKebab.get(part);
if (!compiledPart) {
throw new SomaRuntimePartError(morfo.kebab, part);
}
return compiledPart;
}
function createRegistration(part: string, opts: SomaRuntimePartBaseOpts): PartRegistration {
const compiledPart = resolvePart(part);
const reg: PartRegistration = {
id: opts.id,
ref: opts.ref,
attachment: undefined,
states: opts.states,
props: opts.props,
parts: opts.parts,
bindings: createBindingsScratch(opts, sources),
meta: compiledPart
};
if (opts.ref) {
reg.attachment = attachRef(opts.ref, (el) => {
untrack(() => opts.onRefChange?.(el));
});
}
registrations.set(part, reg);
return reg;
}
function syncPartAttrs(reg: PartRegistration, compiledPart: CompiledPart): void {
// Effect: whenever the part's ref attaches OR any source changes,
// recompute the morfo-derived attrs and write them via dom.apply.
// Reading `reg.ref.current` is the reactive subscription point — the
// `attachRef` setter mutates it when Svelte's attachment callback fires.
//
// `staticAttrs` are pre-resolved at compile time (literals + role) and
// applied unconditionally; `dynamicAttrs` is the slice that requires
// per-tick evaluation against the bindings.
$effect(() => {
const target = reg.ref ? reg.ref.current : null;
if (!target) return;
const bindings = readBindings(reg, sources);
const resolved: Record<string, unknown> = { ...compiledPart.staticAttrs };
for (const plan of compiledPart.dynamicAttrs) {
resolved[plan.attr] = evalAttrPlan(plan, bindings);
}
sources.dom.apply({ target, attrs: resolved as Record<string, DomAttrValue> });
});
}
function part<Owner = unknown>(
partName: string,
opts: SomaRuntimePartOpts<Owner>
): SomaRuntimePart {
const compiledPart = resolvePart(partName);
const reg = createRegistration(partName, opts);
if (opts.syncAttrs) syncPartAttrs(reg, compiledPart);
if (opts.context && opts.owner !== undefined) opts.context.set(opts.owner);
return {
get attachment() {
return reg.attachment;
},
get props() {
return partPropsForRegistration(partName, reg);
},
resolveProps(bindings: MorfoBindings = {}) {
const props: Record<string, unknown> = { ...compiledPart.staticAttrs };
for (const plan of compiledPart.dynamicAttrs) {
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
return props;
},
renderProps() {
// The full render bag for a part that composes its attrs in Svelte
// props (rather than `syncAttrs: true`): static identity + every
// morfo-declared static/dynamic attr, resolved against THIS part's
// registered sources (`opts.props/states/parts` merged with the
// component-level sources). The caller spreads this and then adds
// ONLY what the morfo can't express — event handlers, formatted
// values (override the raw morfo value), native form attrs. This is
// how "morfo declares, soma executes" holds without re-declaring
// role/aria/data in the provider.
const bindings = readBindings(reg, sources);
const props: Record<string, unknown> = {
...partPropsForRegistration(partName, reg),
...compiledPart.staticAttrs
};
for (const plan of compiledPart.dynamicAttrs) {
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
return props;
},
assert<P extends Record<string, unknown>>(props: P): P {
assertContract(compiled.kebab, partName, props, sources.logger);
return props;
}
};
}
function partProps(part: string): Record<string, unknown> {
const reg = registrations.get(part);
if (!reg) return {};
return partPropsForRegistration(part, reg);
}
function partPropsForRegistration(part: string, reg: PartRegistration): Record<string, unknown> {
const marker = compiled.parts.attrs[part];
const props: Record<string, unknown> = {
id: reg.id.current
};
if (marker) props[marker] = '';
// `data-archetype` is part of static identity (cross-component
// classification, never mutates), so it ships through partProps —
// not through dom.apply. Eidos reads it via `[data-archetype=trigger]`.
if (reg.meta.archetype) props['data-archetype'] = reg.meta.archetype;
if (reg.attachment) Object.assign(props, reg.attachment);
return props;
}
function matchesParsedKey(parsed: ParsedKey, event: KeyboardEvent): boolean {
if (event.key !== parsed.key) return false;
if (event.shiftKey !== parsed.shift) return false;
if (event.ctrlKey !== parsed.ctrl) return false;
if (event.altKey !== parsed.alt) return false;
if (event.metaKey !== parsed.meta) return false;
return true;
}
function keydown(part: string, event: KeyboardEvent): boolean {
const compiledPart = compiled.parts.byKebab.get(part);
if (!compiledPart || compiledPart.keyboard.length === 0) return false;
for (const plan of compiledPart.keyboard) {
if (!matchesParsedKey(plan.key, event)) continue;
// Skip entries whose condition is currently falsy.
const reg = registrations.get(part);
const bindings = reg ? readBindings(reg, sources) : EMPTY_BINDINGS;
if (!shouldEmitMorfoEntry(plan.condition, bindings)) continue;
const handler = sources.actions?.[plan.action];
if (!handler) return false;
handler(event);
return true;
}
return false;
}
function snapshotRootProps(): Record<string, unknown> {
if (!rootPropsScratch || !sources.props) return EMPTY_ROOT_PROPS;
clearBindingsBucket(rootPropsScratch);
readSourcesInto(rootPropsScratch, sources.props);
return rootPropsScratch;
}
async function trigger(eventName: string, opts: TriggerOptions = {}): Promise<TriggerResult> {
const action = compiled.actions.byName.get(eventName);
if (!action) {
throw new SomaRuntimeEventError(morfo.kebab, eventName);
}
// 1. prewrite — transient markers applied before the perceptual signal.
// Each prewrite entry already carries a literal value; no source resolution.
for (const write of action.prewrite) {
const writeReg = registrations.get(write.part.target);
const writeTarget = writeReg?.ref?.current ?? null;
if (writeTarget) {
sources.dom.apply({ target: writeTarget, attrs: { [write.attr]: write.value } });
}
}
// 2 & 3. eventEngine.emit ↔ handler — order depends on the morfo
// event's declared `sequence`:
//
// 'pre' (default) — emit BEFORE the handler. The perceptual
// signal precedes the structural commit;
// useful for "announce, then change" cases
// (open: visual+sound first, state flips to
// 'open' after).
//
// 'coincident' — same as 'pre' (emit awaited; handler runs
// immediately after). For events where the
// two are temporally indivisible.
//
// 'post' — handler runs FIRST, then emit. The signal
// celebrates the resolved state — toggle's
// commit-toggle, dialog's close-save. The
// pulse arrives AFTER the state has flipped,
// so the user perceives the consequence of
// their action, not its anticipation.
//
// Polymorphic event resolution (book §5.3). The morfo's regular
// `family` + `intent` + `verb` + `sequence` form the DEFAULT shape.
// When the morfo additionally declares `allowedFamilies`, providers
// MAY pass `semantic` in opts to concrete to one of those families
// instead. The override must be in `allowedFamilies` (the morfo's
// own family is implicitly always allowed).
let resolvedFamily: SemaFamily = action.semantic.family;
let resolvedIntent: Intent | MorfoSemanticIntent | undefined = action.semantic.intent;
let resolvedVerb: string | undefined = action.semantic.verb;
const resolvedSequence: 'pre' | 'coincident' | 'post' | undefined = action.semantic.sequence;
if (isPolymorphicSemantic(action.semantic) && opts.semantic) {
const allowed = action.semantic.allowedFamilies;
const provided = opts.semantic;
// The morfo's declared family is implicitly always allowed —
// authors don't have to repeat themselves in `allowedFamilies`.
const implicitlyAllowed = provided.family === action.semantic.family;
if (!implicitlyAllowed && !allowed.includes(provided.family)) {
throw new SomaRuntimePolymorphicError(eventName, provided.family, allowed);
}
resolvedFamily = provided.family;
resolvedIntent = provided.intent;
resolvedVerb = provided.verb;
}
void resolvedVerb; // currently unused at trigger-time; reserved for tooling/logging.
// `MorfoEventSequence` doc (morfo/types.ts §280): "default 'pre'
// preserves current runtime semantics". Polymorphic events use the
// resolved sequence from above; concrete events read it directly.
const sequence = resolvedSequence ?? 'pre';
const handler = sources.events?.[eventName];
// Persistence: morfo declares; per-call opts override. Default
// `'transient'` (engine auto-cleans after hold). For non-transient
// values, caller owns the cleanup via `runtime.clearSignal(id)`
// or `runtime.clearTarget(target)`.
const persistence: SignalPersistence =
opts.persistence ?? action.semantic.persistence ?? 'transient';
// A11y semantics (book §9.1). Read once; honored after emit.
const a11y = action.a11ySemantic;
const prefersReducedMotion = sources.dom.prefersReducedMotion.matches;
const reducedFallback = a11y?.reducedMotionFallback;
// When the user prefers reduced motion AND the event declares a
// `'state'` fallback, the morfo asks us to silence the perceptual
// signal entirely (no motion, no haptic, no sound) and rely on the
// state attrs the runtime writes naturally. We do that by forcing
// `channels: []` which the engine short-circuits without dispatching.
const a11yChannelsOverride =
prefersReducedMotion && reducedFallback === 'state' ? ([] as const) : undefined;
let emittedId: string | undefined;
const runEmit = async () => {
if (!sources.eventEngine) return;
const targetReg = registrations.get(action.target);
const target = opts.fallbackTarget ?? targetReg?.ref?.current ?? null;
if (!target) {
throw new SomaRuntimeTargetError(eventName, action.target);
}
const props = snapshotRootProps();
const family = resolvedFamily;
// Intent comes from the resolved morfo declaration (concrete
// `intent` field for non-polymorphic, or the polymorphic
// override / declared default for polymorphic). Components that
// want a consumer prop to flow through declare `intent:
// { fromProp: 'intent', … }` on the event. Transitional families
// (emerge / shift / sustain) MAY declare intent now (canon
// update); when omitted, the event has no intent and
// intent.deltas don't apply.
const intent =
resolvedIntent !== undefined ? resolveIntent(resolvedIntent, props) : undefined;
const hold = resolveSemaDuration(action.hold);
// Per-call overrides win over morfo-declared. Channels: per-call
// fully replaces morfo's. Overrides: shallow per-channel merge
// (per-call channel slices win wholesale on conflict). a11y
// reduced-motion override has the LOWEST precedence so authors
// can still force motion when they know the context warrants it.
const channels =
opts.channels ?? action.semantic.channels ?? a11yChannelsOverride;
const morfoOverrides = action.semantic.overrides;
const overrides = opts.overrides
? ({ ...(morfoOverrides ?? {}), ...opts.overrides } as SemaSignatureOverride)
: morfoOverrides;
emittedId = await sources.eventEngine.emit({
target,
name: action.name,
family,
...(intent ? { intent } : {}),
...(hold !== undefined ? { hold } : {}),
...(channels !== undefined ? { channels } : {}),
...(overrides ? { overrides } : {}),
persistence
});
};
const runA11y = () => {
if (!a11y) return;
// Live region — push the caller-provided message text to the
// shared screen-reader region. Triggers ALSO when
// reducedMotionFallback is 'text' so the user gets a textual
// substitute for the motion they don't see.
const wantsAnnounce =
a11y.requiresLiveRegion || (prefersReducedMotion && reducedFallback === 'text');
if (wantsAnnounce && opts.message && sources.announce) {
// CANONICAL live-region policy (SEM-1, 2026-07-11): urgency rides
// the evaluative INTENT alone — threat/loss interrupt, everything
// else waits its turn — matching sema's AnnounceChannel
// (`priorityForIntent`, chans/announce.ts) so BOTH announcement
// paths agree. The old `family === 'signal'` conjunct made a
// threat-tinted commit failure announce polite here and assertive
// via the channel. Uses the RESOLVED intent so polymorphic events
// get the right priority too.
const intent =
resolvedIntent !== undefined
? resolveIntent(resolvedIntent, snapshotRootProps())
: undefined;
const priority: 'polite' | 'assertive' =
intent === 'threat' || intent === 'loss' ? 'assertive' : 'polite';
sources.announce(opts.message, priority);
}
// Focus move — bring keyboard focus to the event's target. Also
// triggered by 'focus' fallback under reduced motion.
const wantsFocus =
a11y.requiresFocusMove || (prefersReducedMotion && reducedFallback === 'focus');
if (wantsFocus) {
const targetReg = registrations.get(action.target);
const target = opts.fallbackTarget ?? targetReg?.ref?.current ?? null;
if (target) sources.dom.focus(target);
}
};
if (sequence === 'post') {
if (handler) await handler();
// `post` means the perceptual signal should see the resolved
// structural DOM, not just the mutated state object. Flush once
// so attrs/props such as data-state or hidden are up to date
// before Sema stamps data-event-*.
await tick();
await runEmit();
} else {
// 'pre' or 'coincident' — emit first, then handler.
await runEmit();
if (handler) await handler();
}
// 4. a11y commitments (book §9.1). Live region + focus move + reduced-
// motion fallback. Runs AFTER the perceptual emit so the message
// reflects the resolved outcome (handler may have flipped state).
runA11y();
// 5. Effects on the affected parts re-derive structural attrs from the
// new state and write them via `dom.apply` automatically — no explicit
// step here. State is the source of truth; the DOM is its derivation.
return {
...(emittedId !== undefined ? { id: emittedId } : {}),
persistence
};
}
function clearSignal(id: string): boolean {
return sources.eventEngine?.clear?.(id) ?? false;
}
function clearTarget(target: HTMLElement): number {
return sources.eventEngine?.clearTarget?.(target) ?? 0;
}
function partRef(part: string): HTMLElement | null {
const reg = registrations.get(part);
return reg?.ref?.current ?? null;
}
return { part, partProps, keydown, trigger, clearSignal, clearTarget, partRef };
}

Powered by TurnKey Linux.