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

1201 lines
50 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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 { ActorRef } from '$libs/actor';
import type { Logger } from '$libs/logger';
import {
resolveSemaDuration,
resolveIntent,
type EngineSemantic,
type SemaChannelId,
type SemaSignatureOverride,
type SignalPersistence
} from '$uix/sema';
import type { Direction } from './types';
import type {
ActionNameOf,
EventNameOf,
EventNameTargeting,
Morfo,
MorfoDirection,
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>;
/**
* 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;
}
/**
* 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;
/** Static render identity: id, marker attr, archetype and ref attachment. */
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>;
/**
* 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;
/**
* 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?: Omit<TriggerOptions, 'targetOverride'>
): 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?: Omit<TriggerOptions, 'targetOverride'>
): 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>;
/**
* 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. 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: 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`.
*/
export interface TriggerOptions {
/**
* The element this emit stamps on, REPLACING the registered ref of the
* event's declared `target` part.
*
* It WINS — always, not only when the declared ref is missing. That is the
* behaviour this option has always had; until 2026-08-10 it was called
* `fallbackTarget`, and the name said the opposite of what the code did
* (`opts.fallbackTarget ?? targetReg?.ref?.current`). The rename is the
* precondition for auditing the redirection: while the option read as a
* fallback, a provider aiming the stamp at a different PART looked
* identical to one aiming at the right instance of the right part.
*
* Two legitimate uses, and the morfo tells them apart:
*
* - **Repeated part.** `registrations` is keyed by part NAME, so a
* repeated part (`day`, `item`, `row`) registers whichever instance
* mounted last. Pointing at the pressed one is the only way to stamp the
* surface the user actually touched. The part is the declared `target`,
* so nothing extra is needed.
* - **A different part.** Redirecting to another part (an overlay stamping
* on its trigger because the content hasn't mounted) MUST be declared in
* the morfo event's `allowedTargets`. Without that declaration a sema
* pack written from the morfo aims at a node the stamp never visits —
* the class measured in audits S-08 / S-15 (dead pack rules) and A-36
* (two morfos stamping one node 2 ms apart).
*
* The runtime asserts this in dev: see `assertTargetOverride`.
*/
targetOverride?: 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;
};
/**
* 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 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<M, PartKebabOf<M>> {
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;
},
trigger(eventName: string, opts: Omit<TriggerOptions, 'targetOverride'> = {}) {
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: Omit<TriggerOptions, 'targetOverride'> = {}) {
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
// `renderProps()` 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;
}
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;
}
/**
* A `targetOverride` may land on the event's declared `target` part (the
* repeated-part case: the pressed `day`, the clicked `item`) or on a part
* the morfo lists in `allowedTargets`. Anything else is undeclared
* redirection: the stamp goes somewhere the contract never mentions, so a
* sema pack or an eidos recipe written FROM the morfo aims at a node the
* signal never visits, and nothing can tell that mistake from a legitimate
* redirection.
*
* Measured instances of the mistake: S-12 (textarea, 6×/13× louder than
* written), S-08 / S-15 (navigation-menu, twice in one file), A-36 (the
* Button's `contact-activate` and the Drawer's `open` on ONE node, 2 ms
* apart — the contact's whole visual signature never painted).
*
* Runtime rather than static: the override is an expression
* (`e.currentTarget`), so only the live element can answer which part it
* is. It reads the markers the element already carries — no extra state.
* A warning, never a throw: sema is ornamental and a mis-aimed stamp must
* not break the component.
*/
function assertTargetOverride(action: { name: string; target: string; semantic: unknown }, el: HTMLElement): void {
if (!sources.logger) return;
const semantic = action.semantic as { allowedTargets?: readonly { target: string }[] };
const allowedParts = [
action.target,
...(semantic.allowedTargets ?? []).map((ref) => ref.target)
];
const allowedMarkers = allowedParts
.map((kebab) => compiled.parts.attrs[kebab])
.filter((marker): marker is string => marker !== undefined);
if (allowedMarkers.some((marker) => el.hasAttribute(marker))) return;
// Name what the element IS, so the report says where the stamp landed
// instead of only where it should have.
const own = Object.values(compiled.parts.attrs).filter((marker) => el.hasAttribute(marker));
sources.logger.warn('soma', `targetOverride on "${compiled.kebab}.${action.name}" lands off-contract`, {
context: {
event: action.name,
declared: allowedParts,
landedOn: own.length > 0 ? own : '(no part marker of this component)',
fix: 'aim at the declared target, or declare the redirection in the morfo event `allowedTargets`'
}
});
}
/**
* 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, mirroring `assertTargetOverride`'s
* rationale: 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 (!(opts.targetOverride ?? 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 = opts.targetOverride ?? resolveEmitTarget(action, anchor);
if (!target) {
throw new SomaRuntimeTargetError(eventName, action.target);
}
if (opts.targetOverride) assertTargetOverride(action, opts.targetOverride);
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 } : {}),
...(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 = opts.targetOverride ?? 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. 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 = liveReg(part);
return reg?.ref?.current ?? null;
}
return { part, partInstance, partProps, keydown, trigger, clearSignal, clearTarget, partRef };
}

Powered by TurnKey Linux.