|
|
/**
|
|
|
* 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, the render bag and contract
|
|
|
* helpers.
|
|
|
* - `partProps(part)` — the FULL render bag (P0 fase C, audit 2026-08-26):
|
|
|
* static identity (id, marker, ref attachment, dir) plus the resolved morfo
|
|
|
* contract — `staticAttrs` (role, literals) and every dynamic plan. It is
|
|
|
* the SINGLE attr pipeline: the same values server-render and re-derive
|
|
|
* through Svelte's reactivity. Before the unification the bag carried
|
|
|
* identity only and the contract lived in a client-only `syncAttrs` effect
|
|
|
* (retired in fase C2c), so 42 providers server-rendered with no role /
|
|
|
* aria-* / data-state.
|
|
|
* - `keydown(part, event)` — dispatch from `morfo.keyboard`.
|
|
|
* - `trigger(eventName)` — orchestrates prewrite + eventEngine.emit + handler.
|
|
|
*
|
|
|
* Operational rules:
|
|
|
* - Nothing writes a part's contract attrs outside the render bag. The one
|
|
|
* sanctioned imperative writer left is `trigger`'s prewrite (a commit the
|
|
|
* morfo declares), which goes through `dom.apply` on its own step.
|
|
|
*
|
|
|
* 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 { ActorRef } from '$libs/actor';
|
|
|
import type { Logger } from '$libs/logger';
|
|
|
import {
|
|
|
resolveSemaDuration,
|
|
|
resolveIntent,
|
|
|
type EngineSemantic,
|
|
|
type SemaChannelId,
|
|
|
type SemaDirection,
|
|
|
type SemaSignatureOverride,
|
|
|
type SignalPersistence
|
|
|
} from '$uix/sema';
|
|
|
|
|
|
import type { Direction } from './types';
|
|
|
import type {
|
|
|
ActionNameOf,
|
|
|
EventNameOf,
|
|
|
EventNameTargeting,
|
|
|
Morfo,
|
|
|
MorfoDirection,
|
|
|
MorfoFocus,
|
|
|
MorfoSemanticIntent,
|
|
|
PartKebabOf
|
|
|
} 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;
|
|
|
|
|
|
/**
|
|
|
* The `dir` requirement, computed from the MORFO — the declaration owns the
|
|
|
* mechanism (direction contract §2):
|
|
|
*
|
|
|
* - morfo declares `direction` ⇒ the runtime REQUIRES `dir`, the raw
|
|
|
* assertion (`Active<Direction | undefined>`, i.e. `opts.dir` — never a
|
|
|
* resolved value). WHERE it stamps comes from the morfo's
|
|
|
* `direction.parts`, validated fail-closed at compile.
|
|
|
* - no `direction` ⇒ the source is FORBIDDEN, so a component with no reading
|
|
|
* direction can never gain a stray attribute.
|
|
|
*
|
|
|
* Both directions of the gate are type errors: a component that accepts a
|
|
|
* `dir` prop and forgets to wire it does not compile, and the ~75 runtimes
|
|
|
* with no direction concept stopped having to say `dir: null` about an axis
|
|
|
* that never concerned them.
|
|
|
*/
|
|
|
export type SomaRuntimeDirSource<M extends Morfo = Morfo> = M extends {
|
|
|
direction: MorfoDirection;
|
|
|
}
|
|
|
? { dir: Active<Direction | undefined> }
|
|
|
: { dir?: never };
|
|
|
|
|
|
export interface SomaRuntimeBaseSources {
|
|
|
/** 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;
|
|
|
/** 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;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The full sources contract, parameterised by the morfo so the `dir`
|
|
|
* requirement is computed from the declaration (see {@link SomaRuntimeDirSource})
|
|
|
* and the handler maps are keyed by DECLARED names. A handler for an event or
|
|
|
* action the morfo does not declare is dead code that reads as if it did
|
|
|
* something — the same drift class the pack census kills on the consumption
|
|
|
* side, closed here at the type level (2026-08-10).
|
|
|
*/
|
|
|
export type SomaRuntimeSources<M extends Morfo = Morfo> = SomaRuntimeBaseSources & {
|
|
|
/**
|
|
|
* Provider's per-event handlers. Each is called by `runtime.trigger` after
|
|
|
* `eventEngine.emit` resolves and is responsible for mutating internal
|
|
|
* state. Keys are the morfo's declared event names.
|
|
|
*/
|
|
|
events?: { [K in EventNameOf<M>]?: EventHandler };
|
|
|
/**
|
|
|
* Provider's per-action handlers, dispatched by `runtime.keydown` against
|
|
|
* the part's `morfo.keyboard` declarations. Keys are the `action` literals
|
|
|
* declared across the morfo's parts (e.g. `'toggle'`, `'close'`).
|
|
|
*/
|
|
|
actions?: { [K in ActionNameOf<M>]?: KeyboardActionHandler };
|
|
|
} & SomaRuntimeDirSource<M>;
|
|
|
|
|
|
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>;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Handle for ONE mounted instance of a part, parameterised by the morfo and
|
|
|
* the part kebab. No default type arguments — same doctrine as
|
|
|
* {@link SomaRuntime}: an untyped handle would anchor any event on any part.
|
|
|
*/
|
|
|
export interface SomaRuntimePart<M extends Morfo, K extends PartKebabOf<M>> {
|
|
|
readonly attachment: RefAttachment | undefined;
|
|
|
/**
|
|
|
* The render bag (P0 fase C, audit 2026-08-26): static identity (id,
|
|
|
* marker attr, archetype, ref attachment, dir) plus every morfo-declared
|
|
|
* attr resolved against THIS part's registered sources. Spread it in a
|
|
|
* part's `props` getter, then add only the soma-specific extras (handlers,
|
|
|
* formatted overrides, native attrs). The single attr pipeline — it
|
|
|
* absorbed the old `the render bag`.
|
|
|
*/
|
|
|
readonly props: Record<string, unknown> & { readonly dir?: Direction };
|
|
|
/** Resolve morfo static/dynamic attrs for render-time legacy/manual props. */
|
|
|
resolveProps(bindings?: MorfoBindings): Record<string, unknown>;
|
|
|
/** Validate an authored prop bag against the morfo data contract. */
|
|
|
assert<P extends Record<string, unknown>>(props: P): P;
|
|
|
/**
|
|
|
* Emit a morfo-declared event ANCHORED to this instance: the stamp (and
|
|
|
* the a11y focus move, when declared) lands on THIS part's element, never
|
|
|
* on the runtime's own resolution. The name is typed to the events whose
|
|
|
* `target` or `allowedTargets` include this part — an event that cannot
|
|
|
* land here cannot be emitted from here.
|
|
|
*
|
|
|
* This is the repeated-part emission surface (the pressed `day`, the
|
|
|
* clicked `item`): the instance the user touched holds the handle, so no
|
|
|
* element ever travels through an untyped option. Mount-state fallback
|
|
|
* (`targetFallback`) applies only to the UN-anchored `runtime.trigger`.
|
|
|
*/
|
|
|
trigger(eventName: EventNameTargeting<M, K>, opts?: TriggerOptions): Promise<TriggerResult>;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Anchored emitter for an instance located by ELEMENT rather than held as a
|
|
|
* handle — `runtime.partInstance(part, el)`. The identity is the registry's:
|
|
|
* `el` must be a registered live instance of `part`, or the lookup is `null`.
|
|
|
*/
|
|
|
export interface SomaPartAnchor<M extends Morfo, K extends PartKebabOf<M>> {
|
|
|
readonly el: HTMLElement;
|
|
|
trigger(eventName: EventNameTargeting<M, K>, opts?: TriggerOptions): Promise<TriggerResult>;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The runtime handle a provider works through, parameterised by ITS morfo.
|
|
|
*
|
|
|
* No default type argument ON PURPOSE: `SomaRuntime` without a morfo would
|
|
|
* accept any part kebab and any event name, which is exactly the anonymous
|
|
|
* surface the 2026-08-10 audit measured (S-12, S-08/S-15, A-36 — emissions
|
|
|
* aimed at names and parts the contract never declared, invisible to every
|
|
|
* consumer). A provider annotates `SomaRuntime<typeof xxxMorfo>`; a helper
|
|
|
* that genuinely abstracts over morfos is generic itself. Widening to
|
|
|
* `SomaRuntime<Morfo>` re-opens the anonymous surface — treat it as drift.
|
|
|
*/
|
|
|
export interface SomaRuntime<M extends Morfo> {
|
|
|
part<K extends PartKebabOf<M>, Owner = unknown>(
|
|
|
part: K,
|
|
|
opts: SomaRuntimePartOpts<Owner>
|
|
|
): SomaRuntimePart<M, K>;
|
|
|
partProps(part: PartKebabOf<M>): Record<string, unknown>;
|
|
|
/**
|
|
|
* The morfo's declared focus policy (eje focus-first, 2026-08-26) — the
|
|
|
* SPECIES DEFAULT the executors consume: `FocusScope.use` takes it as its
|
|
|
* `policy` fallback (`trapFocus ?? modal ?? policy.trap`), and roving
|
|
|
* providers wire `RovingFocusGroup` (or their signed custom executor)
|
|
|
* from it. Per-instance props always win. `undefined` = the component
|
|
|
* declares no focus policy.
|
|
|
*/
|
|
|
readonly focus: MorfoFocus | undefined;
|
|
|
/**
|
|
|
* Resolve an ELEMENT to the registered live instance of `part` it is, and
|
|
|
* return an anchored emitter for it — or `null` when the element is not a
|
|
|
* registered instance (identity by reference against the per-instance
|
|
|
* registry, never by attribute sniffing). For providers that locate a
|
|
|
* repeated part's instance by query (`querySelector`) instead of holding
|
|
|
* its handle.
|
|
|
*/
|
|
|
partInstance<K extends PartKebabOf<M>>(part: K, el: HTMLElement): SomaPartAnchor<M, K> | null;
|
|
|
/**
|
|
|
* 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: PartKebabOf<M>, 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. the render bag re-derives structural attrs from the new state and
|
|
|
* Svelte re-renders them (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: EventNameOf<M>, 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: PartKebabOf<M>): 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`.
|
|
|
*
|
|
|
* There is deliberately NO way to hand this API an element. The emission
|
|
|
* surface is resolved from the DECLARATION and the REGISTRY, nothing else:
|
|
|
* a singleton lands on its registered instance, a repeated part is anchored
|
|
|
* through its own typed handle (`SomaRuntimePart.trigger` /
|
|
|
* `partInstance(...).trigger`, gated by `EventNameTargeting`), and an
|
|
|
* unmounted target degrades through the morfo's `targetFallback` chain.
|
|
|
* The `targetOverride?: HTMLElement` escape hatch that used to live here —
|
|
|
* born `fallbackTarget`, renamed 2026-08-10, censused to zero and DELETED
|
|
|
* 2026-08-13 — existed to compensate for a registry without instance
|
|
|
* identity, and it is what let three audits (fable S1, sema S-17, blocks
|
|
|
* A-36/A-65) find the same anonymous-element drift without any of them being
|
|
|
* able to close it. An element option here would reopen that class at the
|
|
|
* type level; do not add one.
|
|
|
*/
|
|
|
export interface TriggerOptions {
|
|
|
/**
|
|
|
* 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[];
|
|
|
/**
|
|
|
* Sense of the traversal this emit performs, forwarded verbatim to the
|
|
|
* signal (`data-event-direction`). Per-call ONLY — the morfo cannot declare
|
|
|
* it, because the same declared event (`shift-navigate`) goes backward on
|
|
|
* one press and forward on the next, and only the caller knows which.
|
|
|
*
|
|
|
* Omit when the route has no clear sense (a jump to a month picked from a
|
|
|
* select): the surface then carries no direction, which is the honest
|
|
|
* answer.
|
|
|
*/
|
|
|
direction?: SemaDirection;
|
|
|
/**
|
|
|
* 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;
|
|
|
};
|
|
|
/**
|
|
|
* Causal actor of this trigger (agent axis, ⚖️2 —
|
|
|
* docs/architecture/agent.md §5). A provider whose public API was
|
|
|
* invoked from a delegated CapabilityCall threads the engine-minted
|
|
|
* token here (typically alongside `semantic: { family: 'delegate',
|
|
|
* verb: 'act' }` on polymorphic events); absent ⇒ user semantics.
|
|
|
* Copied verbatim onto the `SemanticSignal` — the runtime never
|
|
|
* constructs nor resolves tokens (minting authority: EngineAgent).
|
|
|
*/
|
|
|
actor?: ActorRef;
|
|
|
}
|
|
|
|
|
|
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<M extends Morfo>(
|
|
|
morfo: M,
|
|
|
sources: SomaRuntimeSources<M>
|
|
|
): SomaRuntime<M> {
|
|
|
// Internal widening: the conditional type narrows `dir` per-morfo for the
|
|
|
// CALLER; in here we only need "an Active or nothing". Same for the handler
|
|
|
// maps: the mapped types gate the CALLER's keys against the declaration;
|
|
|
// in here lookups run on runtime strings.
|
|
|
const dirSource = (sources as { dir?: Active<Direction | undefined> }).dir;
|
|
|
const eventHandlers = sources.events as Partial<Record<string, EventHandler>> | undefined;
|
|
|
const actionHandlers = sources.actions as
|
|
|
| Partial<Record<string, KeyboardActionHandler>>
|
|
|
| undefined;
|
|
|
const compiled = registerMorfo(morfo);
|
|
|
/**
|
|
|
* Per-INSTANCE part registry (2026-08-10). A repeated part (`day`, `item`,
|
|
|
* `row`) mounts many instances, and the old `Map<part, registration>` kept
|
|
|
* only the last one — the defect that forced providers to smuggle elements
|
|
|
* through `targetOverride`, and that let a dead newest instance SHADOW a
|
|
|
* live older one (unmount order decided what the runtime could see).
|
|
|
*
|
|
|
* Each `part()` call appends its registration; for parts with a ref, the
|
|
|
* attachment stream maintains membership — detached instances leave the
|
|
|
* bucket, re-attached ones rejoin at the end. Ref-less parts stay put (no
|
|
|
* element, no liveness to track). Resolution is {@link liveReg}: the newest
|
|
|
* ATTACHED instance, falling back to the newest entry so a not-yet-attached
|
|
|
* part behaves exactly as before (null ref → the target checks throw/skip).
|
|
|
*/
|
|
|
const registrations = new Map<string, PartRegistration[]>();
|
|
|
const rootPropsScratch = hasSourceKeys(sources.props) ? {} : undefined;
|
|
|
// Which parts carry the `dir` stamp. Defaults to the provider root, which is
|
|
|
// where 44 of the 49 hand-written stamps sat; the five that paint elsewhere
|
|
|
// (a root that renders no element, a portalled content, a field's input)
|
|
|
// name their parts (see `SomaRuntimeDir`).
|
|
|
// WHERE the stamp lands comes from the morfo (normalised at compile:
|
|
|
// `['provider']` default applied there; unknown names threw at compile).
|
|
|
const dirParts = new Set<string>(compiled.direction?.parts ?? []);
|
|
|
|
|
|
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
|
|
|
};
|
|
|
|
|
|
const bucket = registrations.get(part) ?? [];
|
|
|
if (bucket.length === 0) registrations.set(part, bucket);
|
|
|
bucket.push(reg);
|
|
|
|
|
|
if (opts.ref) {
|
|
|
reg.attachment = attachRef(opts.ref, (el) => {
|
|
|
// Registry membership follows the attachment: a detached instance
|
|
|
// leaves (a virtualised repeated part would otherwise grow the
|
|
|
// bucket without bound), a re-attached one rejoins at the end,
|
|
|
// which also makes it the newest for {@link liveReg}.
|
|
|
if (el) {
|
|
|
if (!bucket.includes(reg)) bucket.push(reg);
|
|
|
} else {
|
|
|
const index = bucket.indexOf(reg);
|
|
|
if (index >= 0) bucket.splice(index, 1);
|
|
|
}
|
|
|
untrack(() => opts.onRefChange?.(el));
|
|
|
});
|
|
|
}
|
|
|
|
|
|
return reg;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The registration that answers for a part today: the newest ATTACHED
|
|
|
* instance, else the newest entry (a not-yet-attached part keeps its
|
|
|
* pre-2026-08-10 behaviour — a null ref the target checks reject). The
|
|
|
* per-instance handles returned by `part()` never go through here; this is
|
|
|
* the runtime's own resolution for morfo-declared targets and bindings.
|
|
|
*/
|
|
|
function liveReg(part: string): PartRegistration | undefined {
|
|
|
const bucket = registrations.get(part);
|
|
|
if (!bucket || bucket.length === 0) return undefined;
|
|
|
for (let i = bucket.length - 1; i >= 0; i--) {
|
|
|
if (bucket[i].ref?.current) return bucket[i];
|
|
|
}
|
|
|
return bucket[bucket.length - 1];
|
|
|
}
|
|
|
|
|
|
function part<Owner = unknown>(
|
|
|
partName: string,
|
|
|
opts: SomaRuntimePartOpts<Owner>
|
|
|
): SomaRuntimePart<M, PartKebabOf<M>> {
|
|
|
const compiledPart = resolvePart(partName);
|
|
|
const reg = createRegistration(partName, opts);
|
|
|
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;
|
|
|
},
|
|
|
assert<P extends Record<string, unknown>>(props: P): P {
|
|
|
assertContract(compiled.kebab, partName, props, sources.logger);
|
|
|
return props;
|
|
|
},
|
|
|
trigger(eventName: string, opts: TriggerOptions = {}) {
|
|
|
return trigger(eventName, opts, reg);
|
|
|
}
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function partInstance(partName: string, el: HTMLElement) {
|
|
|
const bucket = registrations.get(partName);
|
|
|
const reg = bucket?.find((candidate) => candidate.ref?.current === el);
|
|
|
if (!reg) return null;
|
|
|
return {
|
|
|
el,
|
|
|
trigger(eventName: string, opts: TriggerOptions = {}) {
|
|
|
return trigger(eventName, opts, reg);
|
|
|
}
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function partProps(part: string): Record<string, unknown> {
|
|
|
const reg = liveReg(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);
|
|
|
|
|
|
// The `dir` stamp. Ships here — the one bag BOTH `props` and
|
|
|
// `the render bag` include — because it was the last thing every provider
|
|
|
// still wrote by hand: 49 copies of the same line, and 20 of 55
|
|
|
// components had simply forgotten it. The morfo DECLARES the direction
|
|
|
// (and which parts carry it); the type computed from that declaration
|
|
|
// REQUIRES the source — so both forgetting to wire it and wiring it onto
|
|
|
// a direction-less component are compile errors.
|
|
|
//
|
|
|
// The ASSERTION, raw (direction contract §2): `undefined` means nobody
|
|
|
// asserted, the attribute is omitted, and the element inherits — from
|
|
|
// the projected `<html dir>`, or from the nearest asserted ancestor.
|
|
|
if (dirSource && dirParts.has(part)) {
|
|
|
const value = dirSource.current;
|
|
|
if (value !== undefined) props.dir = value;
|
|
|
}
|
|
|
|
|
|
// The morfo contract itself (P0 fase C, audit 2026-08-26): `staticAttrs`
|
|
|
// (role + unconditional literals) and every non-`consumerWins` dynamic
|
|
|
// plan resolve into the render bag, so the contract reaches
|
|
|
// server-rendered HTML and re-derives through Svelte's own reactivity —
|
|
|
// `readBindings` reads rune-backed sources, so any template or $derived
|
|
|
// spreading this bag re-renders when a state/prop source changes. Before
|
|
|
// this, the whole slice lived only in the client `syncAttrs` effect and
|
|
|
// 42 providers server-rendered with no role / aria-* / data-state /
|
|
|
// type="button" (a Toggle in a form defaulted to type="submit").
|
|
|
Object.assign(props, reg.meta.staticAttrs);
|
|
|
let bindings: MorfoBindings | undefined;
|
|
|
for (const plan of reg.meta.dynamicAttrs) {
|
|
|
if (plan.consumerWins) continue;
|
|
|
bindings ??= readBindings(reg, sources);
|
|
|
const value = evalAttrPlan(plan, bindings);
|
|
|
if (value !== undefined) props[plan.attr] = value;
|
|
|
}
|
|
|
|
|
|
// Naming defaults (`consumerWins` plans — `aria-label`) go LAST and are
|
|
|
// the only plans `mergeProps` resolves consumer-first. An imperative
|
|
|
// post-render writer would overwrite the consumer's attr on every pass —
|
|
|
// the A-85 class the retired syncAttrs effect had to tiptoe around.
|
|
|
// Reads are reactive: `translate` is `langs.ts()` (rune-backed locale),
|
|
|
// so the default re-renders on locale change.
|
|
|
for (const plan of reg.meta.dynamicAttrs) {
|
|
|
if (!plan.consumerWins) continue;
|
|
|
bindings ??= readBindings(reg, sources);
|
|
|
const value = evalAttrPlan(plan, bindings);
|
|
|
if (value !== undefined) props[plan.attr] = value;
|
|
|
}
|
|
|
|
|
|
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 = liveReg(part);
|
|
|
const bindings = reg ? readBindings(reg, sources) : EMPTY_BINDINGS;
|
|
|
if (!shouldEmitMorfoEntry(plan.condition, bindings)) continue;
|
|
|
|
|
|
const handler = actionHandlers?.[plan.action];
|
|
|
// No handler registered for THIS plan — keep scanning: another plan
|
|
|
// on the same key (different condition / action) may still handle it
|
|
|
// (SOM-2, clean-room 2026-07-10: `return false` here aborted the
|
|
|
// loop and swallowed the later plans).
|
|
|
if (!handler) continue;
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The emit surface an occurrence lands on. One resolution for the three
|
|
|
* readers (pre-validation, the emit itself, the a11y focus move), so they
|
|
|
* can never disagree:
|
|
|
*
|
|
|
* anchor — a part-anchored emission (`SomaRuntimePart.trigger` /
|
|
|
* `partInstance(...).trigger`) stamps ITS instance, or nothing:
|
|
|
* an anchored emit from a detached instance is a target error,
|
|
|
* never a silent redirection elsewhere.
|
|
|
* else — the declared `target`'s newest live instance, then the
|
|
|
* morfo's `targetFallback` chain in order.
|
|
|
*/
|
|
|
function resolveEmitTarget(
|
|
|
action: { target: string; targetFallback: readonly string[] },
|
|
|
anchor: PartRegistration | undefined
|
|
|
): HTMLElement | null {
|
|
|
if (anchor) return anchor.ref?.current ?? null;
|
|
|
const primary = liveReg(action.target)?.ref?.current;
|
|
|
if (primary) return primary;
|
|
|
for (const part of action.targetFallback) {
|
|
|
const el = liveReg(part)?.ref?.current;
|
|
|
if (el) return el;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Dev guard for the anchored path — the ONLY door an emission can name an
|
|
|
* instance through, now that the `targetOverride` element option is gone
|
|
|
* (deleted 2026-08-13; census 0). The TYPE gate (`EventNameTargeting`)
|
|
|
* makes an off-contract anchor inexpressible in TS, so this warn exists
|
|
|
* for JS callers only. A warning, never a throw — sema is ornamental.
|
|
|
*/
|
|
|
function assertAnchor(
|
|
|
action: { name: string; target: string; semantic: unknown },
|
|
|
anchor: PartRegistration
|
|
|
): void {
|
|
|
if (!sources.logger) return;
|
|
|
const semantic = action.semantic as { allowedTargets?: readonly { target: string }[] };
|
|
|
const allowed = [action.target, ...(semantic.allowedTargets ?? []).map((ref) => ref.target)];
|
|
|
if (allowed.includes(anchor.meta.kebab)) return;
|
|
|
sources.logger.warn(
|
|
|
'soma',
|
|
|
`anchored trigger "${compiled.kebab}.${action.name}" from part "${anchor.meta.kebab}" is off-contract`,
|
|
|
{
|
|
|
context: {
|
|
|
event: action.name,
|
|
|
anchoredOn: anchor.meta.kebab,
|
|
|
declared: allowed,
|
|
|
fix: 'anchor on the declared target or an `allowedTargets` part'
|
|
|
}
|
|
|
}
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function trigger(
|
|
|
eventName: string,
|
|
|
opts: TriggerOptions = {},
|
|
|
anchor?: PartRegistration
|
|
|
): Promise<TriggerResult> {
|
|
|
const result = runTrigger(eventName, opts, anchor);
|
|
|
// Pre-attach a logging catch so fire-and-forget dispatch (`void
|
|
|
// runtime.trigger(...)` — the dominant call shape in providers) can
|
|
|
// never surface as an UNHANDLED rejection when a race rejects the
|
|
|
// emit (e.g. SomaRuntimeTargetError on a part unmounted mid-flight).
|
|
|
// The ORIGINAL promise is returned, so awaiting callers still get the
|
|
|
// rejection intact (SEM-2, clean-room 2026-07-10).
|
|
|
result.catch((err) => {
|
|
|
sources.logger?.error('soma', `trigger "${eventName}" rejected`, { error: err });
|
|
|
});
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
async function runTrigger(
|
|
|
eventName: string,
|
|
|
opts: TriggerOptions = {},
|
|
|
anchor?: PartRegistration
|
|
|
): Promise<TriggerResult> {
|
|
|
const action = compiled.actions.byName.get(eventName);
|
|
|
if (!action) {
|
|
|
throw new SomaRuntimeEventError(morfo.kebab, eventName);
|
|
|
}
|
|
|
if (anchor) assertAnchor(action, anchor);
|
|
|
|
|
|
// 0. Validate the emit target BEFORE any DOM write (SO4, fable audit
|
|
|
// 2026-07-01). The prewrite used to run first, so a target that had
|
|
|
// unmounted mid-flight left `data-last-action` written on the DOM and
|
|
|
// then threw — a half-applied state whose only witness was an attribute
|
|
|
// nobody would clear. Nothing observable happens until we know the
|
|
|
// occurrence can complete.
|
|
|
//
|
|
|
// ONLY for `pre` / `coincident`, where the emit precedes the handler and
|
|
|
// the target must therefore already exist. A `post` event's target may
|
|
|
// legitimately MOUNT during the trigger (the overlay `open`s: the
|
|
|
// handler flips state, `tick()` mounts the content, the emit stamps it),
|
|
|
// so validating it here would reject the canonical open flow. `post`
|
|
|
// keeps its emit-time check inside `runEmit` — and if that one fails,
|
|
|
// the handler has already done the component's real work; only the
|
|
|
// ornament is lost, which is sema's contract.
|
|
|
if (sources.eventEngine && (action.semantic.sequence ?? 'pre') !== 'post') {
|
|
|
if (!resolveEmitTarget(action, anchor)) {
|
|
|
throw new SomaRuntimeTargetError(eventName, action.target);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 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 = liveReg(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;
|
|
|
} else if (opts.semantic) {
|
|
|
// SO2 (fable audit 2026-07-01): `TriggerOptions.semantic`'s own JSDoc
|
|
|
// promised a warn here and there was none, so a provider that passed a
|
|
|
// concretion to a NON-polymorphic event believed it had overridden the
|
|
|
// family and had not. Silence made the two cases — honoured and
|
|
|
// ignored — indistinguishable at the call site.
|
|
|
sources.logger?.warn(
|
|
|
'soma',
|
|
|
`trigger("${eventName}", { semantic }) ignored — the event is not polymorphic`,
|
|
|
{
|
|
|
context: {
|
|
|
event: eventName,
|
|
|
declaredFamily: action.semantic.family,
|
|
|
requestedFamily: opts.semantic.family,
|
|
|
fix: 'declare `allowedFamilies` on the morfo event, or drop the `semantic` option'
|
|
|
}
|
|
|
}
|
|
|
);
|
|
|
}
|
|
|
// The verb TRAVELS (fixed 2026-08-06, audit S-40 — it used to be computed
|
|
|
// and discarded here while the type promised it was emitted). It selects
|
|
|
// the family's default sound: an `emerge` + `close` finds `close` in the
|
|
|
// map's verb table, so no overlay pack has to name it.
|
|
|
|
|
|
// `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 = eventHandlers?.[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.
|
|
|
//
|
|
|
// S5 (fable audit 2026-07-01) — this WINS over the morfo's and the
|
|
|
// call's `channels`. It used to be the last term of a `??` chain, so a
|
|
|
// morfo tuning its channels (`channels: ['sound']`, to keep a live
|
|
|
// region from competing with haptic) silently defeated a user
|
|
|
// ACCESSIBILITY PREFERENCE. The old comment defended it as deliberate
|
|
|
// ("authors can still force motion"), but it conflated two different
|
|
|
// intents: choosing which channels express an event is the author's;
|
|
|
// deciding whether motion reaches this user is not.
|
|
|
const a11yChannelsOverride =
|
|
|
prefersReducedMotion && reducedFallback === 'state' ? ([] as const) : undefined;
|
|
|
|
|
|
let emittedId: string | undefined;
|
|
|
|
|
|
const runEmit = async () => {
|
|
|
if (!sources.eventEngine) return;
|
|
|
const target = resolveEmitTarget(action, anchor);
|
|
|
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).
|
|
|
//
|
|
|
// The a11y reduced-motion silence is FIRST, not last (S5): a user
|
|
|
// preference is not a default for an author to fall back on.
|
|
|
const channels = a11yChannelsOverride ?? opts.channels ?? action.semantic.channels;
|
|
|
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,
|
|
|
...(resolvedVerb ? { verb: resolvedVerb } : {}),
|
|
|
...(intent ? { intent } : {}),
|
|
|
// The sense of the traversal, when the caller knows it. No morfo
|
|
|
// term to fall back on: it is per-emit by nature.
|
|
|
...(opts.direction !== undefined ? { direction: opts.direction } : {}),
|
|
|
...(hold !== undefined ? { hold } : {}),
|
|
|
...(channels !== undefined ? { channels } : {}),
|
|
|
...(overrides ? { overrides } : {}),
|
|
|
...(opts.actor !== undefined ? { actor: opts.actor } : {}),
|
|
|
// Surface concurrency policy, declared by the morfo. Only the
|
|
|
// irreducible pairs set it (`SemaRegime`); everything else takes
|
|
|
// the default `replace`.
|
|
|
...(action.regime !== undefined ? { regime: action.regime } : {}),
|
|
|
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 target = resolveEmitTarget(action, anchor);
|
|
|
if (target) sources.dom.focus(target);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
if (sequence === 'post') {
|
|
|
// Handlers are synchronous by the V1 contract (`EventHandler` — a
|
|
|
// returned value, promises included, is IGNORED, never awaited nor
|
|
|
// settled; async work belongs in the call-site BEFORE trigger).
|
|
|
// The old `await handler()` contradicted that contract and delayed
|
|
|
// a11y + TriggerResult behind out-of-contract async handlers
|
|
|
// (SOM-1, clean-room 2026-07-10).
|
|
|
if (handler) 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) 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. The render bag re-derives structural attrs from the new state and
|
|
|
// Svelte re-renders them 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 = liveReg(part);
|
|
|
return reg?.ref?.current ?? null;
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
part,
|
|
|
partInstance,
|
|
|
partProps,
|
|
|
keydown,
|
|
|
trigger,
|
|
|
clearSignal,
|
|
|
clearTarget,
|
|
|
partRef,
|
|
|
focus: compiled.focus
|
|
|
};
|
|
|
}
|